Dynamic Element Toggle Panel at web page

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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) {}
})();