Dynamic Element Toggle Panel at web page

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

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