Scrollbar Customizer

Fix sites w/white scroll bars on a light theme, vice versa!

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Scrollbar Customizer
// @namespace    https://greasyfork.org/en/users/922168-mark-zinzow
// @version      11.8
// @description  Fix sites w/white scroll bars on a light theme, vice versa!
// @author       Mark Zinzow
// @match        *://*/*
// @exclude      *://chromewebstore.google.com/*
// @exclude      chrome://*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-start
// @license MIT
// ==/UserScript==
/* jshint esversion: 9 */
/* eslint-disable no-multi-spaces */
// Let's put the version number in our dialog title next time.

(function() {
    'use strict';

    // ====================================================================
    // 1. GLOBAL SETTINGS & SITE CONFIG
    // ====================================================================
    const DEFAULT_CONFIG = {
        enabled: false,
        matchFavicon: false,
        width: 18, // Also update 'w: 18' in presets and Panic Buttons if changing this!
        outlineWidth: 2,
        outlineColor: '#00FF00',
        thumbColor: 'transparent',
        trackColor: 'transparent'
    };

    const currentDomain = window.location.hostname;

    let storage = GM_getValue('scrollbarConfig_v11', {});
    let siteConfig = storage[currentDomain] || { ...DEFAULT_CONFIG };
    if (typeof siteConfig.matchFavicon === 'undefined') siteConfig.matchFavicon = false;

    let globalTheme = GM_getValue('globalUITheme', 'dark');

    // Tracks the currently applied settings (handles live UI previews before saving)
    window._usbActiveConfig = { ...siteConfig };

    // ====================================================================
    // 2. CSS ENGINE (Scoped)
    // ====================================================================
    const cssSkeleton = `
        :root {
            --usb-width: 16px;
            --usb-outline-width: 2px;
            --usb-outline-color: #00FF00;
            --usb-thumb-color: transparent;
            --usb-track-color: transparent;
        }

        /* Target the HTML root explicitly as well as all children */
        html[data-usb-enabled="true"],
        html[data-usb-enabled="true"] * {
            scrollbar-width: auto !important;
            scrollbar-color: var(--usb-outline-color) var(--usb-track-color) !important;
        }

        html[data-usb-enabled="true"]::-webkit-scrollbar,
        html[data-usb-enabled="true"] *::-webkit-scrollbar {
            width: var(--usb-width) !important;
            height: var(--usb-width) !important;
            background: var(--usb-track-color) !important;
        }

        html[data-usb-enabled="true"]::-webkit-scrollbar-track,
        html[data-usb-enabled="true"] *::-webkit-scrollbar-track {
            background: var(--usb-track-color) !important;
        }

        html[data-usb-enabled="true"]::-webkit-scrollbar-thumb,
        html[data-usb-enabled="true"] *::-webkit-scrollbar-thumb {
            background-color: var(--usb-thumb-color) !important;
            background-clip: padding-box !important;
            border: var(--usb-outline-width) solid var(--usb-outline-color) !important;
            border-radius: 0px !important;
        }

        html[data-usb-enabled="true"]::-webkit-scrollbar-thumb:hover,
        html[data-usb-enabled="true"] *::-webkit-scrollbar-thumb:hover {
            background-color: var(--usb-outline-color) !important;
            border-color: var(--usb-outline-color) !important;
        }

        html[data-usb-enabled="true"]::-webkit-scrollbar-corner,
        html[data-usb-enabled="true"] *::-webkit-scrollbar-corner {
            background: var(--usb-track-color) !important;
        }
    `;

    GM_addStyle(cssSkeleton);

    // --- FAVICON LOGIC ---
    let faviconTimeout;
    function updateFavicon(color, enable) {
        let link = document.querySelector("link[rel~='icon']");

        if (!enable) {
            if (window._originalFaviconHref && link) {
                link.href = window._originalFaviconHref;
            }
            return;
        }

        if (!link) {
            link = document.createElement('link');
            link.rel = 'icon';
            document.head.appendChild(link);
        }

        if (!window._originalFaviconHref) {
            window._originalFaviconHref = link.href || '/favicon.ico';
        }

        const canvas = document.createElement('canvas');
        canvas.width = 32;
        canvas.height = 32;
        const ctx = canvas.getContext('2d');

        const drawSolidFallback = () => {
            ctx.fillStyle = color;
            ctx.fillRect(0, 0, 32, 32);
            link.href = canvas.toDataURL('image/png');
        };

        const img = new Image();
        img.crossOrigin = 'anonymous';

        img.onload = () => {
            ctx.fillStyle = color;
            ctx.fillRect(0, 0, 32, 32);
            try {
                ctx.drawImage(img, 2, 2, 28, 28);
                link.href = canvas.toDataURL('image/png');
            } catch (e) {
                drawSolidFallback();
            }
        };

        img.onerror = drawSolidFallback;
        img.src = window._originalFaviconHref;
    }

    function debouncedFaviconUpdate(color, enable) {
        clearTimeout(faviconTimeout);
        faviconTimeout = setTimeout(() => {
            updateFavicon(color, enable);
        }, 150);
    }

    // --- MUTATION OBSERVER ---
    const headObserver = new MutationObserver((mutations) => {
        if (!window._usbActiveConfig.enabled || !window._usbActiveConfig.matchFavicon) return;

        let shouldUpdate = false;
        for (const mutation of mutations) {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(node => {
                    if (node.tagName === 'LINK' && (node.rel.includes('icon'))) {
                        if (!node.href.startsWith('data:image')) {
                            window._originalFaviconHref = node.href;
                            shouldUpdate = true;
                        }
                    }
                });
            } else if (mutation.type === 'attributes' && mutation.target.tagName === 'LINK' && mutation.target.rel.includes('icon')) {
                if (mutation.attributeName === 'href' && !mutation.target.href.startsWith('data:image')) {
                     window._originalFaviconHref = mutation.target.href;
                     shouldUpdate = true;
                }
            }
        }
        if (shouldUpdate) {
            debouncedFaviconUpdate(window._usbActiveConfig.outlineColor, true);
        }
    });

    function initObserver() {
        if (document.head) {
            headObserver.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
        } else {
            document.addEventListener('DOMContentLoaded', () => {
                headObserver.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
            });
        }
    }
    initObserver();

    function updatePageVariables(config) {
        window._usbActiveConfig = config;
        const root = document.documentElement;

        if (config.enabled) {
            root.setAttribute('data-usb-enabled', 'true');
        } else {
            root.removeAttribute('data-usb-enabled');
            debouncedFaviconUpdate(null, false);
            return;
        }

        root.style.setProperty('--usb-width', config.width + 'px');
        root.style.setProperty('--usb-outline-width', config.outlineWidth + 'px');
        root.style.setProperty('--usb-outline-color', config.outlineColor);
        root.style.setProperty('--usb-thumb-color', config.thumbColor);
        root.style.setProperty('--usb-track-color', config.trackColor);

        if (config.matchFavicon) {
            debouncedFaviconUpdate(config.outlineColor, true);
        } else {
            debouncedFaviconUpdate(null, false);
        }
    }

    updatePageVariables(siteConfig);

    window.addEventListener('DOMContentLoaded', () => {
        if (siteConfig.enabled && siteConfig.matchFavicon) {
            updatePageVariables(siteConfig);
        }
    });

    function saveSiteConfig(newConfig) {
        siteConfig = newConfig;
        storage[currentDomain] = siteConfig;
        GM_setValue('scrollbarConfig_v11', storage);
        updatePageVariables(siteConfig);
    }

    // ====================================================================
    // 3. MENU "PANIC BUTTONS"
    // ====================================================================

    GM_registerMenuCommand("⚡ Force: Black on White", () => {
        saveSiteConfig({
            enabled: true, matchFavicon: siteConfig.matchFavicon,
            width: 18, outlineWidth: 0, outlineColor: '#000000', thumbColor: '#000000', trackColor: '#FFFFFF'
        });
    });

    GM_registerMenuCommand("⚡ Force: White on Black", () => {
        saveSiteConfig({
            enabled: true, matchFavicon: siteConfig.matchFavicon,
            width: 18, outlineWidth: 0, outlineColor: '#FFFFFF', thumbColor: '#FFFFFF', trackColor: '#000000'
        });
    });

    GM_registerMenuCommand("⚙️ Customize Scrollbars (UI)", createUI);

    // ====================================================================
    // 4. UI BUILDER
    // ====================================================================
    function createEl(tag, styles = {}, parent = null) {
        const el = document.createElement(tag);
        for (const [key, value] of Object.entries(styles)) {
            el.style[key] = value;
        }
        if (parent) parent.appendChild(el);
        return el;
    }

    const PALETTE_COLORS = ['#000000', '#FFFFFF', '#808080', '#C0C0C0', '#FF0000', '#FF8800', '#00FF00', '#00FFFF', '#0000FF', '#FF00FF'];

    function createControlGroup(labelText, value, parent, isColor = false, isTransparentSupported = false) {
        const wrapper = createEl('div', { marginBottom: '12px', paddingBottom: '5px', borderBottom: '1px solid var(--border)' }, parent);
        const header = createEl('div', { display: 'flex', justifyContent: 'space-between', marginBottom: '5px' }, wrapper);
        createEl('span', { fontSize: '12px', color: 'var(--text-dim)' }, header).textContent = labelText;

        let input, chkTransparent;

        if (isColor) {
            const controls = createEl('div', { display: 'flex', gap: '8px', alignItems: 'center' }, header);
            input = createEl('input', { width: '40px', height: '25px', border: '1px solid #777', cursor: 'pointer', padding: '0', background: 'none' }, controls);
            input.type = 'color';
            input.title = "Open System Picker";
            input.value = (value === 'transparent') ? '#000000' : value;

            if (isTransparentSupported) {
                const lbl = createEl('label', { fontSize: '10px', display: 'flex', alignItems: 'center', gap: '3px', color: 'var(--text)' }, controls);
                chkTransparent = createEl('input', {}, lbl);
                chkTransparent.type = 'checkbox';
                chkTransparent.checked = (value === 'transparent');
                createEl('span', {}, lbl).textContent = 'None';

                const toggle = () => {
                    input.disabled = chkTransparent.checked;
                    input.style.opacity = chkTransparent.checked ? '0.3' : '1';
                };
                chkTransparent.addEventListener('change', toggle);
                toggle();
            }
            const palContainer = createEl('div', { display: 'flex', gap: '4px', marginBottom: '8px', flexWrap: 'wrap' }, wrapper);
            PALETTE_COLORS.forEach(color => {
                const swatch = createEl('div', { width: '18px', height: '18px', background: color, border: '1px solid #555', cursor: 'pointer' }, palContainer);
                swatch.onclick = () => {
                    input.value = color;
                    if (chkTransparent) { chkTransparent.checked = false; input.disabled = false; input.style.opacity = '1'; }
                    input.dispatchEvent(new Event('input', { bubbles: true }));
                };
            });
        } else {
            input = createEl('input', { width: '50px', background: 'var(--input-bg)', color: 'var(--text)', border: '1px solid #777', padding: '2px' }, header);
            input.type = 'number';
            input.value = value;
        }
        return { input, chkTransparent };
    }

    let uiContainer = null;

    function createUI() {
        if (uiContainer) { uiContainer.remove(); uiContainer = null; return; }

        const host = createEl('div', { position: 'fixed', top: '10px', right: '10px', zIndex: '2147483647', fontFamily: 'sans-serif' });
        document.body.appendChild(host);
        uiContainer = host;
        const shadow = host.attachShadow({ mode: 'open' });

        const themeStyles = globalTheme === 'dark'
            ? { bg: '#1a1a1a', text: '#ffffff', textDim: '#cccccc', inputBg: '#333', border: '#444' }
            : { bg: '#f4f4f4', text: '#000000', textDim: '#333333', inputBg: '#ffffff', border: '#cccccc' };

        const panel = createEl('div', {
            background: themeStyles.bg, color: themeStyles.text,
            padding: '15px', border: `2px solid ${themeStyles.border}`, borderRadius: '8px', width: '310px',
            boxShadow: '0 10px 40px rgba(0,0,0,0.5)',
            '--text': themeStyles.text, '--text-dim': themeStyles.textDim, '--input-bg': themeStyles.inputBg, '--border': themeStyles.border
        }, shadow);

        const headRow = createEl('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px', borderBottom: `1px solid ${themeStyles.border}`, paddingBottom: '5px' }, panel);
        createEl('h3', { margin: '0', fontSize: '14px', textTransform: 'uppercase', color: 'var(--text-dim)' }, headRow).textContent = 'Scrollbar Config';

        const topBtns = createEl('div', { display: 'flex', gap: '8px' }, headRow);

        const btnTheme = createEl('button', { background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }, topBtns);
        btnTheme.textContent = globalTheme === 'dark' ? '☀️' : '🌙';
        btnTheme.title = "Switch Panel Theme";
        btnTheme.onclick = () => {
            globalTheme = globalTheme === 'dark' ? 'light' : 'dark';
            GM_setValue('globalUITheme', globalTheme);
            host.remove(); uiContainer = null; createUI();
        };

        const btnClose = createEl('button', { background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px', color: 'var(--text-dim)', fontWeight: 'bold' }, topBtns);
        btnClose.textContent = '✕';
        btnClose.title = "Close (Keeps changes on current tab, but does not save)";
        btnClose.onclick = () => {
            host.remove(); uiContainer = null;
        };

        const presetGrid = createEl('div', { display: 'flex', gap: '4px', marginBottom: '15px', height: '35px' }, panel);
        const presets = [
            { name: 'Outline', w: 18, out: 2, outC: '#00FF00', thC: 'transparent', trC: 'transparent' },
            { name: 'Classic', w: 18, out: 1, outC: '#808080', thC: '#C0C0C0',     trC: '#FFFFFF' },
            { name: 'BnW',     w: 18, out: 0, outC: '#000000', thC: '#000000',     trC: '#FFFFFF' },
            { name: 'WnB',     w: 18, out: 0, outC: '#FFFFFF', thC: '#FFFFFF',     trC: '#000000' },
            { name: 'Neon Y',  w: 18, out: 0, outC: '#FFFF00', thC: '#FFFF00',     trC: '#000000' },
            { name: 'Neon P',  w: 18, out: 0, outC: '#FF00FF', thC: '#FF00FF',     trC: '#000000' },
            { name: 'Neon C',  w: 18, out: 0, outC: '#00FFFF', thC: '#00FFFF',     trC: '#000000' },
            { name: 'Neon R',  w: 18, out: 0, outC: '#FF0000', thC: '#FF0000',     trC: '#000000' },
            { name: 'Neon O',  w: 18, out: 0, outC: '#FF8800', thC: '#FF8800',     trC: '#000000' },
            { name: 'Neon G',  w: 18, out: 0, outC: '#00FF00', thC: '#00FF00',     trC: '#000000' }
        ];

        presets.forEach(p => {
            const btn = createEl('button', { flex: '1', padding: '0', background: '#333', border: '1px solid #555', cursor: 'pointer', borderRadius: '4px', position: 'relative', overflow: 'hidden' }, presetGrid);
            btn.title = p.name;
            createEl('div', { width: '100%', height: '100%', background: '#444' }, btn);
            const track = createEl('div', { position: 'absolute', top: '2px', bottom: '2px', right: '30%', left: '30%', background: (p.trC === 'transparent') ? 'none' : p.trC, border: (p.trC === 'transparent') ? '1px dashed #666' : 'none' }, btn);
            createEl('div', { position: 'absolute', top: '20%', height: '40%', left: '0', right: '0', backgroundColor: (p.thC === 'transparent') ? 'transparent' : p.thC, border: `${p.out}px solid ${p.outC}`, boxSizing: 'border-box' }, track);

            btn.onclick = () => {
                inpEnable.checked = true;
                ctrlWidth.input.value = p.w;
                ctrlOutlineW.input.value = p.out;
                ctrlOutlineC.input.value = p.outC;
                if (p.thC === 'transparent') { ctrlThumbC.chkTransparent.checked = true; ctrlThumbC.input.disabled = true; }
                else { ctrlThumbC.chkTransparent.checked = false; ctrlThumbC.input.disabled = false; ctrlThumbC.input.value = p.thC; }
                if (p.trC === 'transparent') { ctrlTrackC.chkTransparent.checked = true; ctrlTrackC.input.disabled = true; }
                else { ctrlTrackC.chkTransparent.checked = false; ctrlTrackC.input.disabled = false; ctrlTrackC.input.value = p.trC; }
                updateLive();
            };
        });

        const rowEnable = createEl('div', { marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '10px', color: 'var(--text)' }, panel);
        const inpEnable = createEl('input', { transform: 'scale(1.2)' }, rowEnable);
        inpEnable.type = 'checkbox';
        // Initialize UI from the active unsaved state to prevent visual jumps
        inpEnable.checked = window._usbActiveConfig.enabled;
        createEl('span', { fontWeight: 'bold' }, rowEnable).textContent = 'Enable Custom Scrollbar';

        const rowFavicon = createEl('div', { marginBottom: '15px', display: 'flex', alignItems: 'center', gap: '10px', color: 'var(--text)' }, panel);
        const inpFavicon = createEl('input', { transform: 'scale(1.2)' }, rowFavicon);
        inpFavicon.type = 'checkbox';
        inpFavicon.checked = window._usbActiveConfig.matchFavicon;
        createEl('span', {}, rowFavicon).textContent = 'Match Favicon Background';

        const ctrlWidth = createControlGroup('Total Width (px)', window._usbActiveConfig.width, panel);
        const ctrlOutlineW = createControlGroup('Outline Thickness (px)', window._usbActiveConfig.outlineWidth, panel);
        const ctrlOutlineC = createControlGroup('Outline Color', window._usbActiveConfig.outlineColor, panel, true, false);
        const ctrlThumbC = createControlGroup('Thumb Fill', window._usbActiveConfig.thumbColor, panel, true, true);
        const ctrlTrackC = createControlGroup('Track Background', window._usbActiveConfig.trackColor, panel, true, true);

        function getValues() {
            return {
                enabled: inpEnable.checked,
                matchFavicon: inpFavicon.checked,
                width: ctrlWidth.input.value,
                outlineWidth: ctrlOutlineW.input.value,
                outlineColor: ctrlOutlineC.input.value,
                thumbColor: ctrlThumbC.chkTransparent.checked ? 'transparent' : ctrlThumbC.input.value,
                trackColor: ctrlTrackC.chkTransparent.checked ? 'transparent' : ctrlTrackC.input.value
            };
        }
        function updateLive() { updatePageVariables(getValues()); }

        inpEnable.addEventListener('change', () => {
            if (!inpEnable.checked) {
                inpFavicon.checked = false;
            }
            updateLive();
        });
        inpFavicon.addEventListener('change', updateLive);

        panel.addEventListener('input', updateLive);

        // Action Buttons Row (Bottom)
        const btnRow = createEl('div', { display: 'flex', gap: '8px', marginTop: '15px' }, panel);

        const btnSave = createEl('button', { flex: '1', padding: '8px', background: '#00d26a', border: 'none', borderRadius: '4px', fontWeight: 'bold', cursor: 'pointer', color: '#000' }, btnRow);
        btnSave.textContent = 'Save';
        btnSave.title = "Save settings permanently for this site";
        btnSave.addEventListener('click', () => {
            saveSiteConfig(getValues());
            const origText = btnSave.textContent;
            btnSave.textContent = 'Saved!';
            setTimeout(() => btnSave.textContent = origText, 1000);
        });

        const btnUndo = createEl('button', { flex: '1', padding: '8px', background: '#e0a800', border: 'none', borderRadius: '4px', fontWeight: 'bold', cursor: 'pointer', color: '#000' }, btnRow);
        btnUndo.textContent = 'Undo';
        btnUndo.title = "Revert to last saved settings";
        btnUndo.addEventListener('click', () => {
            inpEnable.checked = siteConfig.enabled;
            inpFavicon.checked = siteConfig.matchFavicon;
            ctrlWidth.input.value = siteConfig.width;
            ctrlOutlineW.input.value = siteConfig.outlineWidth;
            ctrlOutlineC.input.value = siteConfig.outlineColor;

            if (siteConfig.thumbColor === 'transparent') { ctrlThumbC.chkTransparent.checked = true; ctrlThumbC.input.disabled = true; }
            else { ctrlThumbC.chkTransparent.checked = false; ctrlThumbC.input.disabled = false; ctrlThumbC.input.value = siteConfig.thumbColor; }

            if (siteConfig.trackColor === 'transparent') { ctrlTrackC.chkTransparent.checked = true; ctrlTrackC.input.disabled = true; }
            else { ctrlTrackC.chkTransparent.checked = false; ctrlTrackC.input.disabled = false; ctrlTrackC.input.value = siteConfig.trackColor; }

            updatePageVariables(siteConfig);
        });

        const btnExit = createEl('button', { flex: '1', padding: '8px', background: '#dc3545', border: 'none', borderRadius: '4px', fontWeight: 'bold', cursor: 'pointer', color: '#fff' }, btnRow);
        btnExit.textContent = 'Exit';
        btnExit.title = "Close (Keeps changes on current tab, but does not save)";
        btnExit.addEventListener('click', () => {
            host.remove(); uiContainer = null;
        });
    }
})();