Zelenka Assist

BBCode-меню для редактора Zelenka

// ==UserScript==
// @name         Zelenka Assist
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  BBCode-меню для редактора Zelenka
// @author       OxD5F
// @match        https://lolz.guru/*
// @match        https://zelenka.guru/*
// @match        https://lolz.live/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=zelenka.guru
// @supportURL   https://zelenka.guru/
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';


    const buttonSVG = `<svg width="20" height="20" fill="none" stroke="#41b883" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="10" rx="3" /><path d="M7 12h10" /></svg>`;
    const apiSVG = `<svg width="20" height="20" fill="none" stroke="#41b883" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>`;
    const censorSVG = `<svg width="20" height="20" fill="none" stroke="#41b883" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C7 19 2.73 15.11 1 12c.74-1.36 1.81-2.85 3.06-4.01"/><path d="M1 1l22 22"/><path d="M9.53 9.53A3.5 3.5 0 0 0 12 15.5c1.09 0 2.08-.48 2.74-1.24"/><path d="M14.47 14.47A3.5 3.5 0 0 0 12 8.5"/><path d="M23 12c-1.73 3.11-6 7-11 7-1.4 0-2.72-.2-3.97-.56"/></svg>`;
    const codeSVG = `<svg width="20" height="20" fill="none" stroke="#41b883" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`;
    const userSVG = `<svg width="20" height="20" fill="none" stroke="#41b883" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M2 20c0-3.5 4.03-6 10-6s10 2.5 10 6"/></svg>`;
    const templateSVG = `<svg width="20" height="20" fill="none" stroke="#41b883" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 8h8M8 12h8M8 16h4"/></svg>`;


    const srciLanguages = [
      {value: '', label: 'Авто'},
      {value: 'python', label: 'Python'},
      {value: 'ruby', label: 'Ruby'},
      {value: 'perl', label: 'Perl'},
      {value: 'php', label: 'PHP'},
      {value: 'xml', label: 'XML'},
      {value: 'html', label: 'HTML'},
      {value: 'css', label: 'CSS'},
      {value: 'javascript', label: 'JavaScript'},
      {value: 'java', label: 'Java'},
      {value: 'cpp', label: 'C++'},
      {value: 'sql', label: 'SQL'},
      {value: 'smalltalk', label: 'Smalltalk'},
      {value: 'ini', label: 'INI'},
      {value: 'dos', label: 'DOS'},
      {value: 'bash', label: 'Bash'},
      {value: 'diff', label: 'DIFF'}
    ];

    const TEMPLATES = [
      {
        name: 'Продажа товара',
        value: `[B]Заголовок:[/B]
[B]Цена:[/B]
[B]Описание:[/B]
[B]Гарантия:[/B]
[B]Связь:[/B]
[B]Способы оплаты:[/B]`
      },
      {
        name: 'Оказание услуги',
        value: `[B]Вид услуги:[/B]
[B]Опыт работы:[/B]
[B]Портфолио:[/B]
[B]Стоимость:[/B]
[B]Связь:[/B]`
      },
      {
        name: 'Отчет/Жалоба',
        value: `[B]ID пользователя:[/B]
[B]Причина жалобы:[/B]
[B]Описание ситуации:[/B]
[B]Доказательства (скриншоты, переписка):[/B]`
      },
      {
        name: 'Гарант/Проверка',
        value: `[B]Суть сделки:[/B]
[B]Стороны:[/B]
[B]Детали (цена, условия):[/B]
[B]Доказательства:[/B]`
      },
      {
        name: 'AI-сгенерировать шаблон',
        value: '',
        isAi: true
      }
    ];

    const menuTriggers = ['!menu', '!m', '!м', '!меню'];
    let activePopup = null;

    function removePopup() {
        if (activePopup) activePopup.remove();
        activePopup = null;
    }

    function getMenuTriggerPosition(editor, explicit) {
        const selection = window.getSelection();
        if (!selection.rangeCount) return null;
        const range = selection.getRangeAt(0).cloneRange();
        let node = range.startContainer;
        let offset = range.startOffset;

        if (explicit && node.nodeType === 3) {
            const tmpRange = document.createRange();
            tmpRange.setStart(node, offset);
            tmpRange.setEnd(node, offset);
            const rects = tmpRange.getClientRects();
            let rect = rects.length ? rects[0] : null;
            return { node, idx: offset, rect, explicitRange: tmpRange };
        }

        if (node.nodeType === 3) {
            const text = node.textContent;
            for (let t of menuTriggers) {
                const idx = text.toLowerCase().lastIndexOf(t, offset);
                if (idx !== -1 && offset >= idx + t.length) {
                    const menuRange = document.createRange();
                    menuRange.setStart(node, idx);
                    menuRange.setEnd(node, idx + t.length);
                    const rects = menuRange.getClientRects();
                    let rect = rects.length ? rects[0] : null;
                    return { node, idx, rect, triggerLength: t.length, trigger: t, range: menuRange };
                }
            }
        }
        return null;
    }

    function positionPopup(popup, rect) {
        let top = 100, left = 300;
        if (rect) {
            top = rect.bottom + window.scrollY + 4;
            left = rect.left + window.scrollX - 8;
        }
        popup.style.top = `${top}px`;
        popup.style.left = `${left}px`;
    }

    function showMenuPopup(rect, onSelectButton, onSelectAPI, onSelectCensor, onSelectSRCI, onSelectVisitor, onSelectTemplate) {
        removePopup();
        const popup = document.createElement('div');
        popup.className = 'fr-popup fr-desktop fr-ltr fe-acPopup fr-above fr-active';
        popup.style.zIndex = 9999;
        popup.style.position = 'absolute';
        popup.style.maxWidth = '400px';
        positionPopup(popup, rect);

        const scrollWrapper = document.createElement('div');
        scrollWrapper.className = 'scroll-wrapper fe-ac fe-ac-user';
        scrollWrapper.style.position = 'relative';

        const scrollContent = document.createElement('div');
        scrollContent.className = 'fe-ac fe-ac-user scroll-content';
        scrollContent.style.maxHeight = '520px';

        function makeMenuItem(svg, label, callback) {
            const div = document.createElement('div');
            div.className = 'fe-ac-user-result fe-ac-result';
            div.style.fontWeight = '600';
            div.style.color = '#41b883';
            div.style.display = 'flex';
            div.style.alignItems = 'center';
            div.style.gap = '10px';
            div.style.cursor = 'pointer';
            div.style.fontSize = '16px';
            div.style.padding = '10px 18px';
            div.innerHTML = svg + label;
            div.onmouseenter = () => div.style.background = 'rgba(65,184,131,0.08)';
            div.onmouseleave = () => div.style.background = '';
            div.onclick = () => {
                popup.remove();
                callback(rect);
            };
            return div;
        }

        scrollContent.appendChild(makeMenuItem(buttonSVG, 'Вставить кнопку', () => showButtonFormPopup(rect, onSelectButton)));
        scrollContent.appendChild(makeMenuItem(apiSVG, 'Вставить API-блок', () => showApiFormPopup(rect, onSelectAPI)));
        scrollContent.appendChild(makeMenuItem(censorSVG, 'Вставить скрытый контент', () => showCensorFormPopup(rect, onSelectCensor)));
        scrollContent.appendChild(makeMenuItem(codeSVG, 'Вставить код (SRCI)', () => showSRCIFormPopup(rect, onSelectSRCI)));
        scrollContent.appendChild(makeMenuItem(userSVG, 'Вставить имя пользователя', () => { removePopup(); onSelectVisitor(); }));
        scrollContent.appendChild(makeMenuItem(templateSVG, 'Вставить шаблон', () => showTemplateFormPopup(rect, onSelectTemplate)));

        scrollWrapper.appendChild(scrollContent);
        popup.appendChild(scrollWrapper);
        document.body.appendChild(popup);

        activePopup = popup;

        setTimeout(() => {
            document.addEventListener('mousedown', function esc(e){
                if (activePopup && !activePopup.contains(e.target)) {
                    removePopup();
                    document.removeEventListener('mousedown', esc, true);
                }
            }, true);
        }, 30);
    }

function replaceMenuWithBBCode(pos, newText, editor, explicit) {
    const selection = window.getSelection();
    selection.removeAllRanges();

    let node = pos.node;
    let triggerLength = pos.triggerLength || 5;
    let idx = pos.idx;

    let range = document.createRange();
    range.setStart(node, idx);
    range.setEnd(node, idx + triggerLength);
    selection.addRange(range);

    document.execCommand('delete', false, null);

    document.execCommand('insertHTML', false, newText.replace(/\n/g, '<br>'));

    if (editor && typeof editor.focus === 'function') {
        editor.focus();
    }
}

    function showButtonFormPopup(rect, onInsert) {
        removePopup();
        const popup = createForumPopup(rect, 350);

        const formWrap = createFormWrap();
        formWrap.appendChild(makeLabel('Ссылка (URL):'));
        const inputUrl = makeInput('https://...');
        formWrap.appendChild(inputUrl);

        formWrap.appendChild(makeLabel('Текст кнопки:'));
        const inputText = makeInput('Текст');
        formWrap.appendChild(inputText);

        const insertBtn = makeInsertBtn(() => {
            const url = inputUrl.value.trim();
            const text = inputText.value.trim();
            if (!url || !text) {
                if(!url) inputUrl.style.border = '1.5px solid #f00';
                if(!text) inputText.style.border = '1.5px solid #f00';
                return;
            }
            removePopup();
            onInsert(url, text);
        });
        formWrap.appendChild(insertBtn);

        formWrap.addEventListener('keydown', makeFormKeys(insertBtn));
        popup.appendChild(formWrap);
        document.body.appendChild(popup);
        activePopup = popup;
        inputUrl.focus();
        focusCloseOnClickOutside();
    }

    function showApiFormPopup(rect, onInsert) {
        removePopup();
        const popup = createForumPopup(rect, 350);
        const formWrap = createFormWrap();

        formWrap.appendChild(makeLabel('API URL:'));
        const inputUrl = makeInput('https://api.example.com/...');
        formWrap.appendChild(inputUrl);

        const insertBtn = makeInsertBtn(() => {
            const url = inputUrl.value.trim();
            if (!url) {
                inputUrl.style.border = '1.5px solid #f00';
                return;
            }
            removePopup();
            onInsert(url);
        });
        formWrap.appendChild(insertBtn);

        formWrap.addEventListener('keydown', makeFormKeys(insertBtn));
        popup.appendChild(formWrap);
        document.body.appendChild(popup);
        activePopup = popup;
        inputUrl.focus();
        focusCloseOnClickOutside();
    }

    function showCensorFormPopup(rect, onInsert) {
        removePopup();
        const popup = createForumPopup(rect, 350);
        const formWrap = createFormWrap();

        formWrap.appendChild(makeLabel('Скрытый текст:'));
        const inputText = makeInput('Текст, который будет скрыт...');
        formWrap.appendChild(inputText);

        const insertBtn = makeInsertBtn(() => {
            const val = inputText.value.trim();
            if (!val) {
                inputText.style.border = '1.5px solid #f00';
                return;
            }
            removePopup();
            onInsert(val);
        });
        formWrap.appendChild(insertBtn);

        formWrap.addEventListener('keydown', makeFormKeys(insertBtn));
        popup.appendChild(formWrap);
        document.body.appendChild(popup);
        activePopup = popup;
        inputText.focus();
        focusCloseOnClickOutside();
    }

    function showSRCIFormPopup(rect, onInsert) {
        removePopup();
        const popup = createForumPopup(rect, 410);
        const formWrap = createFormWrap();

        formWrap.appendChild(makeLabel('Язык (подсветка):'));
        const selectLang = document.createElement('select');
        selectLang.style.background = '#181a1b';
        selectLang.style.border = '1px solid #36393b';
        selectLang.style.color = '#d1d5da';
        selectLang.style.borderRadius = '6px';
        selectLang.style.padding = '7px 10px';
        selectLang.style.fontSize = '15px';
        selectLang.style.marginBottom = '10px';
        srciLanguages.forEach(lang => {
            const opt = document.createElement('option');
            opt.value = lang.value;
            opt.textContent = lang.label;
            selectLang.appendChild(opt);
        });
        formWrap.appendChild(selectLang);

        formWrap.appendChild(makeLabel('Код:'));
        const inputCode = makeInput('Введите код...');
        formWrap.appendChild(inputCode);

        const insertBtn = makeInsertBtn(() => {
            const lang = selectLang.value;
            const code = inputCode.value.trim();
            if (!code) {
                inputCode.style.border = '1.5px solid #f00';
                return;
            }
            removePopup();
            onInsert(lang, code);
        });
        formWrap.appendChild(insertBtn);

        formWrap.addEventListener('keydown', makeFormKeys(insertBtn));
        popup.appendChild(formWrap);
        document.body.appendChild(popup);
        activePopup = popup;
        selectLang.focus();
        focusCloseOnClickOutside();
    }

    function showTemplateFormPopup(rect, onInsert) {
        removePopup();
        const popup = createForumPopup(rect, 430);
        const formWrap = createFormWrap();

        const label = makeLabel('Выберите шаблон:');
        formWrap.appendChild(label);

        const select = document.createElement('select');
        select.style.background = '#181a1b';
        select.style.border = '1px solid #36393b';
        select.style.color = '#d1d5da';
        select.style.borderRadius = '6px';
        select.style.padding = '7px 10px';
        select.style.fontSize = '15px';
        select.style.marginBottom = '10px';

        TEMPLATES.forEach((tpl, i) => {
            const opt = document.createElement('option');
            opt.value = i;
            opt.textContent = tpl.name;
            select.appendChild(opt);
        });

        formWrap.appendChild(select);

        const input = document.createElement('textarea');
        input.rows = 8;
        input.style.background = '#181a1b';
        input.style.border = '1px solid #36393b';
        input.style.color = '#d1d5da';
        input.style.borderRadius = '6px';
        input.style.padding = '8px 10px';
        input.style.fontSize = '15px';
        input.style.marginBottom = '10px';
        input.value = TEMPLATES[0].value;
        formWrap.appendChild(input);

        select.onchange = async function() {
            const selected = TEMPLATES[select.value];
            if (selected.isAi) {
                input.value = "Введите тему или кратко опишите, что нужно (например: шаблон продажи аккаунта Steam, отчет о гарант-сделке и т.п.)";
                input.readOnly = false;
                input.focus();
                // Можно прикрутить AI через API
            } else {
                input.value = selected.value;
                input.readOnly = false;
            }
        };

        const insertBtn = makeInsertBtn(() => {
            if (!input.value.trim()) {
                input.style.border = '1.5px solid #f00';
                return;
            }
            removePopup();
            onInsert(input.value);
        });
        formWrap.appendChild(insertBtn);

        formWrap.addEventListener('keydown', makeFormKeys(insertBtn));
        popup.appendChild(formWrap);
        document.body.appendChild(popup);
        activePopup = popup;
        select.focus();
        focusCloseOnClickOutside();
    }

    function createForumPopup(rect, maxWidth) {
        const popup = document.createElement('div');
        popup.className = 'fr-popup fr-desktop fr-ltr fe-acPopup fr-above fr-active';
        popup.style.zIndex = 9999;
        popup.style.position = 'absolute';
        popup.style.maxWidth = (maxWidth || 370) + 'px';
        positionPopup(popup, rect);
        return popup;
    }
    function createFormWrap() {
        const div = document.createElement('div');
        div.style.display = 'flex';
        div.style.flexDirection = 'column';
        div.style.gap = '5px';
        div.style.padding = '16px 20px 15px 20px';
        return div;
    }
    function makeLabel(text) {
        const label = document.createElement('label');
        label.textContent = text;
        label.style.color = '#bbb';
        label.style.fontSize = '13px';
        label.style.marginBottom = '2px';
        return label;
    }
    function makeInput(placeholder) {
        const input = document.createElement('input');
        input.type = 'text';
        input.placeholder = placeholder;
        input.style.background = '#181a1b';
        input.style.border = '1px solid #36393b';
        input.style.color = '#d1d5da';
        input.style.borderRadius = '6px';
        input.style.padding = '7px 10px';
        input.style.fontSize = '15px';
        input.style.marginBottom = '10px';
        return input;
    }
    function makeInsertBtn(onClick) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.textContent = 'Вставить';
        btn.style.background = '#41b883';
        btn.style.color = '#fff';
        btn.style.fontWeight = '600';
        btn.style.border = 'none';
        btn.style.borderRadius = '6px';
        btn.style.fontSize = '15px';
        btn.style.padding = '8px 22px';
        btn.style.cursor = 'pointer';
        btn.style.marginTop = '4px';
        btn.onmouseenter = () => btn.style.background = '#28c76f';
        btn.onmouseleave = () => btn.style.background = '#41b883';
        btn.onclick = onClick;
        return btn;
    }
    function makeFormKeys(btn) {
        return function(e) {
            if (e.key === 'Enter') {
                e.preventDefault();
                btn.click();
            }
            if (e.key === 'Escape') removePopup();
        }
    }
    function focusCloseOnClickOutside() {
        setTimeout(() => {
            document.addEventListener('mousedown', function esc(e){
                if (activePopup && !activePopup.contains(e.target)) {
                    removePopup();
                    document.removeEventListener('mousedown', esc, true);
                }
            }, true);
        }, 30);
    }



    function attachEditorMenu(editor) {
        if (editor._bbmenu_attached) return;
        editor._bbmenu_attached = true;

        editor.addEventListener('keyup', function(e){
            const pos = getMenuTriggerPosition(editor, false);
            if (pos && pos.rect) {
                showMenuPopup(
                    pos.rect,
                    (url, text) => replaceMenuWithBBCode(pos, `[BUTTON=${url}]${text}[/BUTTON]`, editor, false),
                    (apiUrl)   => replaceMenuWithBBCode(pos, `[api]${apiUrl}[/api]`, editor, false),
                    (censor)   => replaceMenuWithBBCode(pos, `[censor]${censor}[/censor]`, editor, false),
                    (lang, code) => {
                        if (lang) replaceMenuWithBBCode(pos, `[SRCI=${lang}]${code}[/SRCI]`, editor, false);
                        else      replaceMenuWithBBCode(pos, `[SRCI]${code}[/SRCI]`, editor, false);
                    },
                    () => replaceMenuWithBBCode(pos, `[visitor][/visitor]`, editor, false),
                    (tpl) => replaceMenuWithBBCode(pos, tpl, editor, false)
                );
            }
        });

        editor.addEventListener('keydown', function(e){
            if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && (e.key === 'm' || e.key === 'M')) {
                e.preventDefault();
                const pos = getMenuTriggerPosition(editor, true);
                if (pos && pos.rect) {
                    showMenuPopup(
                        pos.rect,
                        (url, text) => replaceMenuWithBBCode(pos, `[BUTTON=${url}]${text}[/BUTTON]`, editor, true),
                        (apiUrl)   => replaceMenuWithBBCode(pos, `[api]${apiUrl}[/api]`, editor, true),
                        (censor)   => replaceMenuWithBBCode(pos, `[censor]${censor}[/censor]`, editor, true),
                        (lang, code) => {
                            if (lang) replaceMenuWithBBCode(pos, `[SRCI=${lang}]${code}[/SRCI]`, editor, true);
                            else      replaceMenuWithBBCode(pos, `[SRCI]${code}[/SRCI]`, editor, true);
                        },
                        () => replaceMenuWithBBCode(pos, `[visitor][/visitor]`, editor, true),
                        (tpl) => replaceMenuWithBBCode(pos, tpl, editor, true)
                    );
                }
            }
        });
    }

    function observeEditors() {
        function applyToAllEditors() {
            document.querySelectorAll('.fr-element[contenteditable="true"]').forEach(attachEditorMenu);
        }
        applyToAllEditors();
        const obs = new MutationObserver(() => {
            applyToAllEditors();
        });
        obs.observe(document.body, { childList: true, subtree: true });
    }

    document.addEventListener('DOMContentLoaded', observeEditors);
    setTimeout(observeEditors, 1500);
})();