Dynamic Element Toggle Panel at web page

Объединённая изолированная панель управления элементами (Shadow DOM, надёжная синхронизация, минимальная нагрузка)

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         Dynamic Element Toggle Panel at web page
// @namespace    gemini
// @version      3.1.0
// @description  Объединённая изолированная панель управления элементами (Shadow DOM, надёжная синхронизация, минимальная нагрузка)
// @author       Wizzergod
// @icon         https://images.icon-icons.com/2072/PNG/96/disable_eye_hidden_hide_internet_security_view_icon_127055.png
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-body
// @noframes     true
// @inject-into  content
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // ---- Константы и конфигурация ----
    const STORAGE_KEY = 'DynamicTogglePanelConfig';
    const HOSTNAME = location.hostname;
    const MAX_FALLBACK_ATTEMPTS = 6;
    const FALLBACK_DELAY_MS = 700;
    const APPLY_DEBOUNCE_MS = 120;

    // ---- Утилиты хранения ----
    const getFullStorage = () => GM_getValue(STORAGE_KEY, {});
    const saveFullStorage = (obj) => GM_setValue(STORAGE_KEY, obj);
    const getSiteConfig = () => (getFullStorage()[HOSTNAME] || []);
    const saveSiteConfig = (arr) => {
        const s = getFullStorage();
        if (arr.length) s[HOSTNAME] = arr; else delete s[HOSTNAME];
        saveFullStorage(s);
    };

    // ---- Изоляция UI через Shadow DOM ----
    const host = document.createElement('div');
    host.id = 'dyn-panel-shadow-host';
    host.style.position = 'fixed';
    host.style.top = '0';
    host.style.left = '0';
    host.style.zIndex = '2147483647';

    (document.body || document.documentElement).appendChild(host);
    const shadow = host.attachShadow({ mode: 'open' });

    // ---- Обновленный и приглаженный CSS ----
    const css = `
        :host { all: initial; z-index: 99989; }

        #dyn-open-btn {
            position: fixed;
            top: 40%;
            left: -30px;
            width: 40px;
            height: 80px;
            background: #1b2838;
            border: 2px solid #2a475e;
            border-radius: 0 12px 12px 0;
            color: white;
            font-size: 18px;
            cursor: pointer;
            z-index: 2147483647;
            box-shadow: 0 2px 10px rgba(0,0,0,.5);
            opacity: 0.6;
            transition: left .3s ease, opacity .2s, background .2s;
            transition-delay: .6s;
        }

        #dyn-open-btn:hover {
            left: 0px;
            background: #2a475e;
            opacity: 0.9;
            transition-delay: 0s;
        }

        #dyn-panel {
            position: fixed;
            top: 10px;
            left: 10px;
            min-width: 320px;
            max-width: 510px;
            background: #1b2838;
            border: 2px solid #2a475e;
            border-radius: 12px;
            padding: 10px;
            color: white;
            z-index: 2147483647;
            font-family: Arial, sans-serif;
            box-shadow: 0 4px 20px rgba(0,0,0,.7);
            max-height: 95vh;
            overflow-y: auto;
            scrollbar-width: thin;
            bottom: 10px;
            opacity: 0;
            transform: scale(0.95) translateY(-10px);
            pointer-events: none;
            transition: opacity 0.2s ease, transform 0.2s ease;
        }

        #dyn-panel.open {
            opacity: 1;
            transform: scale(1) translateY(0);
            pointer-events: auto;
        }

        .dyn-panel-title { margin: 0 0 15px 0; text-align: center; font-size: 16px; font-weight: bold; }
        #dyn-toggles-container { max-height: 575px; min-height: 280px; overflow-y: auto; background: #101720cc; padding: 5px; border-radius: 12px; scrollbar-width: thin; border: 1px solid #2a475e; }
        #dyn-no-items { font-size: 13px; opacity: 0.7; text-align: center; margin: 10px 0; }

        .toggle-item { display: flex; justify-content: space-between; align-items: center; margin: 0 0 4px 0; border: 1px solid transparent; transition: background .2s, border .2s; }
        .toggle-item:hover { background: rgba(185, 255, 199, 0.03); border: 1px solid #2a475e; }
        .toggle-item:nth-child(odd) { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 4px 6px; }
        .toggle-item:nth-child(even) { background: #192635; border-radius: 12px; padding: 4px 6px; }

        .toggle-item-controls { display: flex; align-items: center; flex-grow: 1; margin-left: 8px; cursor: pointer; }
        .toggle-label { font-size: 14px; flex-grow: 1; margin-right: 10px; word-break: break-word; user-select: none; cursor: pointer; }

        .toggle-switch { position: relative; display: inline-block; width: 40px; height: 20px; flex-shrink: 0; cursor: pointer; }
        .toggle-switch input { opacity: 0; width: 0; height: 0; }
        .toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: #2a475e; transition: .3s; border-radius: 12px; }
        .toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background: white; transition: .3s; border-radius: 12px; }

        input:checked + .toggle-slider { background-color: #5c7e10; }
        input:checked + .toggle-slider:before { transform: translateX(20px); }

        .panel-buttons { display: flex; gap: 5px; margin-top: 15px; }
        .panel-btn { flex: 1; padding: 7px; border: none; border-radius: 12px; cursor: pointer; font-size: 12px; font-weight: bold; transition: background-color 0.2s ease; }

        .show-all-btn { background: #5c7e10; color: white; }
        .show-all-btn:hover { background: #749f14; }
        .hide-all-btn { background: #7e1010; color: white; }
        .hide-all-btn:hover { background: #b21616; }

        .dyn-panel-separator { border: 0; border-top: 1px solid #2a475e; margin: 12px 0; }
        .dyn-add-form { display: flex; flex-direction: column; gap: 10px; }
        .dyn-panel-author { font-size: 13px; font-weight: bold; margin: 5px 0 0 0; text-align: center; opacity: 0.6; }

        .panel-input { width: 100%; padding: 8px; background: #101822; border: 1px solid #2a475e; color: white; border-radius: 12px; box-sizing: border-box; font-size: 13px; transition: background-color 0.2s ease; }
        .panel-input:hover, .panel-input:focus { background: #151f2d; outline: none; }

        .add-btn { background: #5c7e10; color: white; padding: 8px; }
        .add-btn:hover { background: #749f14; }

        .delete-btn { background: #2a475e; color: white; border: none; border-radius: 12px; width: 22px; height: 22px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; padding: 0; flex-shrink: 0; transition: background-color 0.2s ease; }
        .delete-btn:hover { background: #ca2121; }

        .clear-all-btn { background: #a01a1a; color: white; width: 100%; }
        .clear-all-btn:hover { background: #ca2121; }
    `;

    const styleEl = document.createElement('style');
    styleEl.textContent = css;
    shadow.appendChild(styleEl);

    // ---- Компоненты UI ----
    const root = document.createElement('div');
    shadow.appendChild(root);

    function buildUI() {
        root.innerHTML = '';
        const openBtn = document.createElement('button');
        openBtn.id = 'dyn-open-btn';
        openBtn.innerHTML = '☰';
        openBtn.title = 'Управление элементами';

        const panel = document.createElement('div');
        panel.id = 'dyn-panel';

        const toggles = document.createElement('div');
        toggles.id = 'dyn-toggles-container';
        panel.appendChild(toggles);

        const btns = document.createElement('div');
        btns.className = 'panel-buttons';
        const showAll = document.createElement('button');
        showAll.className = 'panel-btn show-all-btn';
        showAll.textContent = 'Показать всё 🔴';
        const hideAll = document.createElement('button');
        hideAll.className = 'panel-btn hide-all-btn';
        hideAll.textContent = 'Скрыть всё 🟢';
        btns.appendChild(showAll);
        btns.appendChild(hideAll);
        panel.appendChild(btns);

        panel.appendChild(Object.assign(document.createElement('hr'), { className: 'dyn-panel-separator' }));
        const form = document.createElement('div');
        form.className = 'dyn-add-form';
        const selectorInput = document.createElement('input');
        selectorInput.className = 'panel-input';
        selectorInput.placeholder = 'CSS селектор';
        form.appendChild(selectorInput);
        const nameInput = document.createElement('input');
        nameInput.className = 'panel-input';
        nameInput.placeholder = 'Название';
        form.appendChild(nameInput);
        const addBtn = document.createElement('button');
        addBtn.className = 'panel-btn add-btn';
        addBtn.textContent = 'Добавить';
        form.appendChild(addBtn);
        panel.appendChild(form);

        panel.appendChild(Object.assign(document.createElement('hr'), { className: 'dyn-panel-separator' }));
        const clearBtn = document.createElement('button');
        clearBtn.className = 'panel-btn clear-all-btn';
        clearBtn.textContent = 'Сбросить всё для сайта';
        panel.appendChild(clearBtn);

        panel.appendChild(Object.assign(document.createElement('hr'), { className: 'dyn-panel-separator' }));
        const sub = document.createElement('h4');
        sub.className = 'dyn-panel-author';
        sub.textContent = '❮ By Wizzergod 2026 ❯';
        panel.appendChild(sub);

        root.appendChild(openBtn);
        root.appendChild(panel);

        // ---- Логика событий открытия/закрытия ----
        openBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            const isOpen = panel.classList.toggle('open');
            if (isOpen) populateToggles();
        });

        document.addEventListener('click', (e) => {
            if (panel.classList.contains('open') && !e.composedPath().includes(host)) {
                panel.classList.remove('open');
            }
        });

        addBtn.addEventListener('click', () => {
            const res = addItem(selectorInput.value.trim(), nameInput.value.trim());
            if (res) {
                selectorInput.value = '';
                nameInput.value = '';
                populateToggles();
            }
        });

        showAll.addEventListener('click', () => { updateAll(true); populateToggles(); });
        hideAll.addEventListener('click', () => { updateAll(false); populateToggles(); });
        clearBtn.addEventListener('click', () => {
            if (confirm('Сбросить все настройки для этого сайта?')) {
                const cfg = getSiteConfig();
                cfg.forEach(i => applyVisibility(i.selector, true));
                saveSiteConfig([]);
                populateToggles();
            }
        });
    }

    // ---- UI: наполнение и элементы ----
    function populateToggles() {
        const cont = root.querySelector('#dyn-toggles-container');
        if (!cont) return;
        cont.innerHTML = '';
        const cfg = getSiteConfig();
        if (cfg.length === 0) {
            cont.innerHTML = '<div id="dyn-no-items">Нет элементов</div>';
            return;
        }
        cfg.forEach(item => cont.appendChild(makeToggle(item)));
    }

    function makeToggle(item) {
        const row = document.createElement('div');
        row.className = 'toggle-item';
        row.dataset.selector = item.selector;

        const del = document.createElement('button');
        del.className = 'delete-btn';
        del.textContent = '⛔';
        del.title = 'Удалить';
        del.addEventListener('click', (e) => {
            e.stopPropagation();
            if (confirm(`Удалить "${item.name}"?`)) removeItem(item.selector);
        });

        const box = document.createElement('label');
        box.className = 'toggle-item-controls';

        const lbl = document.createElement('span');
        lbl.className = 'toggle-label';
        lbl.textContent = item.name;
        lbl.title = item.selector;

        const sw = document.createElement('span');
        sw.className = 'toggle-switch';

        const inp = document.createElement('input');
        inp.type = 'checkbox';
        inp.checked = !!item.visible;

        const sld = document.createElement('span');
        sld.className = 'toggle-slider';

        inp.addEventListener('change', () => {
            applyVisibility(item.selector, inp.checked);
            saveItemVisibility(item.selector, inp.checked);
        });

        sw.appendChild(inp);
        sw.appendChild(sld);
        box.appendChild(lbl);
        box.appendChild(sw);
        row.appendChild(del);
        row.appendChild(box);

        return row;
    }

    // ---- CRUD для настроек ----
    function addItem(selector, name) {
        if (!selector || !name) { alert('Заполните оба поля'); return false; }

        // Валидация корректности CSS селектора
        try {
            document.createDocumentFragment().querySelector(selector);
        } catch(e) {
            alert('Недопустимый CSS селектор. Пожалуйста, проверьте синтаксис.');
            return false;
        }

        const cfg = getSiteConfig();
        if (cfg.find(i => i.selector === selector)) { alert('Этот селектор уже добавлен'); return false; }

        cfg.push({ selector: selector, name: name, visible: true });
        saveSiteConfig(cfg);
        applyVisibility(selector, true);
        return true;
    }

    function removeItem(selector) {
        const cfg = getSiteConfig().filter(i => i.selector !== selector);
        applyVisibility(selector, true);
        saveSiteConfig(cfg);
        populateToggles();
    }

    function saveItemVisibility(selector, isVisible) {
        const cfg = getSiteConfig();
        const it = cfg.find(i => i.selector === selector);
        if (it) { it.visible = isVisible; saveSiteConfig(cfg); }
    }

    function updateAll(isVisible) {
        const cfg = getSiteConfig();
        cfg.forEach(i => { i.visible = isVisible; applyVisibility(i.selector, isVisible); });
        saveSiteConfig(cfg);
    }

    // ---- Надежное применение видимости ----
    function applyVisibility(selector, visible) {
        try {
            document.querySelectorAll(selector).forEach(el => {
                el.style.setProperty('display', visible ? '' : 'none', 'important');
            });
        } catch(e) {
            console.error('[TogglePanel] Ошибка применения селектора:', selector, e);
        }
    }

    let queuedSelectors = new Set();
    let applyTimer = null;

    function scheduleApply(selector) {
        queuedSelectors.add(selector);
        debounceApply();
    }

    function debounceApply() {
        if (applyTimer) clearTimeout(applyTimer);
        applyTimer = setTimeout(() => {
            const arr = Array.from(queuedSelectors);
            queuedSelectors.clear();
            if (arr.length === 0) return;

            const runLogic = () => {
                const cfg = getSiteConfig();
                arr.forEach(s => {
                    const target = cfg.find(i => i.selector === s);
                    applyVisibility(s, target ? target.visible : true);
                });
            };

            if ('requestIdleCallback' in window) {
                requestIdleCallback(runLogic, { timeout: 1000 });
            } else {
                setTimeout(runLogic, 0);
            }
        }, APPLY_DEBOUNCE_MS);
    }

    // ---- Оптимизированный MutationObserver ----
    function startObserver() {
        const observer = new MutationObserver(mutations => {
            const cfg = getSiteConfig();
            if (cfg.length === 0) return;

            let needUpdate = false;
            // Быстрый выход из цикла мутаций, чтобы убрать лишнюю нагрузку O(N)
            for (const m of mutations) {
                if ((m.addedNodes && m.addedNodes.length > 0) || m.type === 'attributes') {
                    needUpdate = true;
                    break;
                }
            }

            if (needUpdate) {
                cfg.forEach(i => queuedSelectors.add(i.selector));
                debounceApply();
            }
        });

        observer.observe(document.documentElement || document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['class', 'id', 'style']
        });

        // Фоллбек-ретраи для динамических SPA-приложений
        (function fallback(attempt) {
            if (attempt > MAX_FALLBACK_ATTEMPTS) return;
            const cfg = getSiteConfig();
            if (cfg.length > 0) {
                cfg.forEach(i => applyVisibility(i.selector, i.visible));
            }
            setTimeout(() => fallback(attempt + 1), FALLBACK_DELAY_MS);
        })(0);
    }

    function initApplyAll() {
        const cfg = getSiteConfig();
        if (cfg.length === 0) return;
        cfg.forEach(i => {
            applyVisibility(i.selector, i.visible);
            scheduleApply(i.selector);
        });
    }

    // ---- Инициализация ----
    buildUI();

    try {
        initApplyAll();
        if ('requestIdleCallback' in window) {
            requestIdleCallback(startObserver, { timeout: 2000 });
        } else {
            setTimeout(startObserver, 600);
        }
    } catch(e) {
        console.error('[TogglePanel] Init error', e);
    }

    // ---- Экспорт в консоль для отладки ----
    try {
        Object.defineProperty(window, '__DynamicTogglePanel', {
            value: { getSiteConfig, saveSiteConfig, applyVisibility },
            writable: false,
            configurable: true
        });
    } catch(e) {}
})();