Discourse Customizable Quick Replies

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

Verzia zo dňa 06.11.2025. Pozri najnovšiu verziu.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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);
    });
  }
})();