Greasy Fork

Greasy Fork is available in English.

Discourse Customizable Quick Replies

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

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