Discourse Customizable Quick Replies

Adds customizable quick replies with a UI to Discourse forums, with enhanced UI theming.

Verze ze dne 06. 11. 2025. Zobrazit nejnovější verzi.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Discourse Customizable Quick Replies
// @namespace    https://github.com/stevessr/bug-v3
// @version      2.1.0
// @description  Adds customizable quick replies with a UI to Discourse forums, with enhanced UI theming.
// @author       stevessr (modified by an AI assistant)
// @match        https://linux.do/*
// @match        https://meta.discourse.org/*
// @match        https://*.discourse.org/*
// @match        http://localhost:5173/*
// @exclude      https://linux.do/a/*
// @match        https://idcflare.com/*
// @grant        none
// @license      MIT
// @homepageURL  https://github.com/stevessr/bug-v3
// @supportURL   https://github.com/stevessr/bug-v3/issues
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // ===== Settings Management =====
  const SETTINGS_KEY = 'custom_quick_replies_settings';
  let quickReplies = [];

  const DEFAULT_QUICK_REPLIES = [
    { display: 'Info', insert: '>[!info]+\n', prefix: '[!info' },
    { display: 'Tip', insert: '>[!tip]+\n', prefix: '[!tip' },
    { display: 'Success', insert: '>[!success]+\n', prefix: '[!success' },
    { display: 'Warning', insert: '>[!warning]+\n', prefix: '[!warning' },
    { display: 'Danger', insert: '>[!danger]+\n', prefix: '[!danger' },
  ];

  function loadQuickReplies() {
    try {
      const settingsData = localStorage.getItem(SETTINGS_KEY);
      if (settingsData) {
        quickReplies = JSON.parse(settingsData);
      } else {
        quickReplies = DEFAULT_QUICK_REPLIES;
      }
    } catch (e) {
      console.warn('[Quick Replies] Failed to load settings:', e);
      quickReplies = DEFAULT_QUICK_REPLIES;
    }
  }

  function saveQuickReplies() {
    try {
      localStorage.setItem(SETTINGS_KEY, JSON.stringify(quickReplies));
    } catch (e) {
      console.error('[Quick Replies] Failed to save settings:', e);
    }
  }

  // ===== Suggestion Box =====
  let suggestionBox = null;
  let activeSuggestionIndex = 0;

  // ===== Entry Point =====
  if (isDiscoursePage()) {
    console.log('[Quick Replies] Discourse detected, initializing...');
    loadQuickReplies();
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', () => {
        initQuickReplyFeatures();
        initQuickInsertButton();
      });
    } else {
      initQuickReplyFeatures();
      initQuickInsertButton();
    }
  }

  // ===== Detailed Implementation =====

  function isDiscoursePage() {
    return document.querySelector('#main-outlet, .ember-application, textarea.d-editor-input, .ProseMirror.d-editor-input');
  }

  function initQuickReplyFeatures() {
    createSuggestionBox();
    document.addEventListener('input', handleInput, true);
    document.addEventListener('keydown', handleKeydown, true);
    document.addEventListener('click', e => {
      if (e.target && e.target.tagName !== 'TEXTAREA' && suggestionBox && !suggestionBox.contains(e.target)) {
        hideSuggestionBox();
      }
    });
    console.log('[Quick Replies] Suggestion features initialized.');
  }

  function initQuickInsertButton() {
    const observer = new MutationObserver(() => {
      const toolbars = document.querySelectorAll('.d-editor-button-bar, .chat-composer__inner-container');
      toolbars.forEach(toolbar => {
        if (!toolbar.querySelector('.quick-reply-settings-btn')) {
          injectQuickInsertButton(toolbar);
        }
      });
    });
    observer.observe(document.body, { childList: true, subtree: true });
    console.log('[Quick Replies] Quick insert button observer initialized.');
  }

  function injectQuickInsertButton(toolbar) {
    const quickInsertButton = document.createElement('button');
    quickInsertButton.className = 'btn no-text btn-icon toolbar__button quick-insert-button';
    quickInsertButton.title = 'Quick Replies';
    quickInsertButton.innerHTML = '⎘';
    quickInsertButton.addEventListener('click', e => {
      e.stopPropagation();
      toggleQuickInsertMenu(quickInsertButton);
    });

    const settingsButton = document.createElement('button');
    settingsButton.className = 'btn no-text btn-icon toolbar__button quick-reply-settings-btn';
    settingsButton.title = 'Quick Reply Settings';
    settingsButton.innerHTML = '⚙️';
    settingsButton.addEventListener('click', e => {
      e.stopPropagation();
      createSettingsUI();
    });

    toolbar.appendChild(quickInsertButton);
    toolbar.appendChild(settingsButton);
  }

  function toggleQuickInsertMenu(button) {
    let menu = document.getElementById('quick-insert-menu');
    if (menu) {
      menu.remove();
      return;
    }
    menu = createQuickInsertMenu();
    document.body.appendChild(menu);
    const rect = button.getBoundingClientRect();
    menu.style.position = 'fixed';
    menu.style.top = `${rect.bottom + 5}px`;
    menu.style.left = `${rect.left}px`;

    const removeMenu = ev => {
      if (menu && !menu.contains(ev.target)) {
        menu.remove();
        document.removeEventListener('click', removeMenu);
      }
    };
    setTimeout(() => document.addEventListener('click', removeMenu), 100);
  }

  function createQuickInsertMenu() {
    const menu = document.createElement('div');
    menu.id = 'quick-insert-menu';
    menu.style.cssText = `
      position: absolute;
      background-color: var(--secondary);
      border: 1px solid var(--primary-low-mid);
      border-radius: 6px;
      box-shadow: var(--d-shadow-medium);
      z-index: 10000;
      padding: 5px;
    `;
    quickReplies.forEach(reply => {
      const item = document.createElement('div');
      item.textContent = reply.display;
      item.style.cssText = `
        padding: 8px 12px;
        cursor: pointer;
        color: var(--primary-high);
        border-radius: 4px;
      `;
      item.onmouseover = () => item.style.backgroundColor = 'var(--d-hover)';
      item.onmouseout = () => item.style.backgroundColor = 'transparent';
      item.addEventListener('click', () => {
        insertIntoEditor(reply.insert);
        toggleQuickInsertMenu(); // Close menu
      });
      menu.appendChild(item);
    });
    return menu;
  }

  function insertIntoEditor(text) {
    const textarea = document.querySelector('textarea.d-editor-input, textarea.ember-text-area');
    if (textarea) {
      const start = textarea.selectionStart;
      const end = textarea.selectionEnd;
      textarea.value = textarea.value.substring(0, start) + text + textarea.value.substring(end);
      textarea.selectionStart = textarea.selectionEnd = start + text.length;
      textarea.focus();
      textarea.dispatchEvent(new Event('input', { bubbles: true }));
    } else {
        document.execCommand('insertText', false, text);
    }
  }

  // --- Suggestion Box Functions ---

  function createSuggestionBox() {
    if (suggestionBox) return;
    suggestionBox = document.createElement('div');
    suggestionBox.id = 'callout-suggestion-box';
    document.body.appendChild(suggestionBox);
    const style = document.createElement('style');
    style.textContent = `
      #callout-suggestion-box {
        position: absolute;
        background-color: var(--secondary);
        border: 1px solid var(--primary-low-mid);
        border-radius: 6px;
        box-shadow: var(--d-shadow-medium);
        z-index: 999999;
        padding: 5px;
        display: none;
      }
      .callout-suggestion-item {
        padding: 8px 12px;
        cursor: pointer;
        color: var(--primary-high);
        border-radius: 4px;
      }
      .callout-suggestion-item.active {
        background-color: var(--d-hover) !important;
      }
    `;
    document.head.appendChild(style);
  }

  function hideSuggestionBox() {
    if (suggestionBox) suggestionBox.style.display = 'none';
  }

  function updateSuggestionBox(element, matches) {
    if (!suggestionBox || matches.length === 0) {
      hideSuggestionBox();
      return;
    }
    suggestionBox.innerHTML = matches.map((reply, index) =>
      `<div class="callout-suggestion-item" data-index="${index}">${reply.display}</div>`
    ).join('');

    suggestionBox.querySelectorAll('.callout-suggestion-item').forEach((item, index) => {
      item.addEventListener('mousedown', e => {
        e.preventDefault();
        applyCompletion(element, matches[index]);
        hideSuggestionBox();
      });
    });

    const cursorPos = getCursorXY(element);
    suggestionBox.style.display = 'block';
    suggestionBox.style.left = `${cursorPos.x}px`;
    suggestionBox.style.top = `${cursorPos.bottom}px`;
    activeSuggestionIndex = 0;
    updateActiveSuggestion();
  }

    function updateActiveSuggestion() {
        if (!suggestionBox) return;
        const items = suggestionBox.querySelectorAll('.callout-suggestion-item');
        items.forEach((item, idx) => {
            item.classList.toggle('active', idx === activeSuggestionIndex);
            if (idx === activeSuggestionIndex) {
                item.scrollIntoView({ block: 'nearest' });
            }
        });
    }

  function handleInput(event) {
    const target = event.target;
    if (!(target instanceof HTMLTextAreaElement)) return;
    const textBeforeCursor = target.value.substring(0, target.selectionStart);
    const matches = quickReplies.filter(reply => textBeforeCursor.endsWith(reply.prefix));
    if (matches.length > 0) {
      updateSuggestionBox(target, matches);
    } else {
      hideSuggestionBox();
    }
  }

  function handleKeydown(event) {
    if (!suggestionBox || suggestionBox.style.display === 'none') return;
    const items = suggestionBox.querySelectorAll('.callout-suggestion-item');
    if (items.length === 0) return;
    if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) {
        event.preventDefault();
        event.stopPropagation();
    }
    switch (event.key) {
        case 'ArrowDown':
            activeSuggestionIndex = (activeSuggestionIndex + 1) % items.length;
            updateActiveSuggestion();
            break;
        case 'ArrowUp':
            activeSuggestionIndex = (activeSuggestionIndex - 1 + items.length) % items.length;
            updateActiveSuggestion();
            break;
        case 'Enter':
            const selectedItem = suggestionBox.querySelector(`.callout-suggestion-item[data-index="${activeSuggestionIndex}"]`);
            if (selectedItem) {
                const textBeforeCursor = document.activeElement.value.substring(0, document.activeElement.selectionStart);
                const matches = quickReplies.filter(reply => textBeforeCursor.endsWith(reply.prefix));
                if (matches.length > 0) {
                    applyCompletion(document.activeElement, matches[activeSuggestionIndex]);
                }
            }
            hideSuggestionBox();
            break;
        case 'Escape':
            hideSuggestionBox();
            break;
    }
  }

  function applyCompletion(element, selectedReply) {
    const text = element.value;
    const selectionStart = element.selectionStart;
    const textBeforeCursor = text.substring(0, selectionStart);
    const triggerIndex = textBeforeCursor.lastIndexOf(selectedReply.prefix);

    if (triggerIndex !== -1) {
        const textAfter = text.substring(selectionStart);
        element.value = text.substring(0, triggerIndex) + selectedReply.insert + textAfter;
        const newCursorPos = triggerIndex + selectedReply.insert.length;
        element.selectionStart = element.selectionEnd = newCursorPos;
        element.dispatchEvent(new Event('input', { bubbles: true }));
    }
  }

  function getCursorXY(element) {
    const mirrorId = 'textarea-mirror-div';
    let mirror = document.getElementById(mirrorId);
    if (!mirror) {
        mirror = document.createElement('div');
        mirror.id = mirrorId;
        document.body.appendChild(mirror);
    }
    const style = window.getComputedStyle(element);
    const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'padding', 'border'];
    props.forEach(p => mirror.style[p] = style[p]);
    mirror.style.position = 'absolute';
    mirror.style.visibility = 'hidden';
    mirror.style.whiteSpace = 'pre-wrap';
    mirror.style.wordWrap = 'break-word';
    mirror.style.left = `${element.offsetLeft}px`;
    mirror.style.top = `${element.offsetTop}px`;
    mirror.style.width = `${element.clientWidth}px`;
    mirror.style.height = 'auto';

    const textUpToCursor = element.value.substring(0, element.selectionEnd);
    mirror.textContent = textUpToCursor;
    const span = document.createElement('span');
    span.textContent = '.';
    mirror.appendChild(span);

    const rect = element.getBoundingClientRect();
    const spanRect = span.getBoundingClientRect();
    return {
        x: rect.left + span.offsetLeft,
        y: rect.top + span.offsetTop,
        bottom: rect.top + span.offsetTop + span.offsetHeight,
    };
  }


  // --- Settings UI ---

  function createSettingsUI() {
    let modal = document.getElementById('quick-reply-settings-modal');
    if (modal) {
      modal.remove();
      return;
    }

    modal = document.createElement('div');
    modal.id = 'quick-reply-settings-modal';
    modal.style.cssText = `
      position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
      width: 600px; max-width: 90vw; max-height: 80vh;
      background-color: var(--secondary);
      color: var(--primary-high);
      border: 1px solid var(--primary-low-mid);
      border-radius: 8px;
      z-index: 10001;
      box-shadow: var(--d-shadow-huge);
      display: flex; flex-direction: column;
    `;

    const header = document.createElement('div');
    header.textContent = 'Quick Reply Settings';
    header.style.cssText = 'font-size: 1.2em; padding: 15px; border-bottom: 1px solid var(--primary-low-mid);';

    const content = document.createElement('div');
    content.id = 'quick-reply-list-container';
    content.style.cssText = 'padding: 15px; overflow-y: auto;';

    const footer = document.createElement('div');
    footer.style.cssText = 'padding: 15px; border-top: 1px solid var(--primary-low-mid); display: flex; justify-content: space-between; align-items: center;';

    const addButton = document.createElement('button');
    addButton.textContent = 'Add New';
    addButton.className = 'btn';
    addButton.onclick = () => {
        quickReplies.push({ display: 'New Reply', insert: '', prefix: ''});
        renderQuickReplyList(content);
    };

    const saveButton = document.createElement('button');
    saveButton.textContent = 'Save and Close';
    saveButton.className = 'btn btn-primary';
    saveButton.onclick = () => {
        saveQuickReplies();
        modal.remove();
    };

    footer.appendChild(addButton);
    footer.appendChild(saveButton);

    modal.appendChild(header);
    modal.appendChild(content);
    modal.appendChild(footer);
    document.body.appendChild(modal);

    renderQuickReplyList(content);
  }

  function renderQuickReplyList(container) {
    container.innerHTML = '';
    quickReplies.forEach((reply, index) => {
        const item = document.createElement('div');
        item.style.cssText = 'display: flex; align-items: center; margin-bottom: 10px; gap: 10px;';

        const inputs = ['display', 'insert', 'prefix'].map(key => {
            const input = document.createElement('input');
            input.type = 'text';
            input.placeholder = key.charAt(0).toUpperCase() + key.slice(1);
            input.value = reply[key];
            input.style.cssText = `
              flex: 1;
              padding: 8px;
              background-color: var(--input-background);
              border: 1px solid var(--primary-low-mid);
              color: var(--primary-high);
              border-radius: 5px;
            `;
            input.oninput = () => quickReplies[index][key] = input.value;
            return input;
        });

        const deleteButton = document.createElement('button');
        deleteButton.textContent = 'Delete';
        deleteButton.className = 'btn btn-danger';
        deleteButton.onclick = () => {
            quickReplies.splice(index, 1);
            renderQuickReplyList(container);
        };

        inputs.forEach(input => item.appendChild(input));
        item.appendChild(deleteButton);
        container.appendChild(item);
    });
  }
})();