Scrollbar Customizer

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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