Greasy Fork

8chan Style Script

Script to style 8chan

目前为 2025-04-19 提交的版本。查看 最新版本

// ==UserScript==
// @name        8chan Style Script
// @namespace   8chanSS
// @match       *://8chan.moe/*
// @match       *://8chan.se/*
// @exclude     *://8chan.se/login.html
// @grant       none
// @version     1.10
// @author      Anon
// @run-at      document-end
// @description Script to style 8chan
// @license     MIT
// ==/UserScript==
(function () {
    var defaultConfig = {}; // TODO add menu and default configs to toggle options

    /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    // Header Catalog Links
    // Function to append /catalog.html to links
    function appendCatalogToLinks() {
        const navboardsSpan = document.getElementById('navBoardsSpan');
        if (navboardsSpan) {
            const links = navboardsSpan.getElementsByTagName('a');
            for (let link of links) {
                if (link.href && !link.href.endsWith('/catalog.html')) {
                    link.href += '/catalog.html';
                }
            }
        }
    }
    // Initial call to append links on page load
    appendCatalogToLinks();

    // Set up a MutationObserver to watch for changes in the #navboardsSpan div
    const observer = new MutationObserver(appendCatalogToLinks);
    const config = { childList: true, subtree: true };

    const navboardsSpan = document.getElementById('navBoardsSpan');
    if (navboardsSpan) {
        observer.observe(navboardsSpan, config);
    }

    /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    // Scroll to last read post
    // Function to save the scroll position
    const MAX_PAGES = 50; // Maximum number of pages to store scroll positions
    const currentPage = window.location.href;

    // Specify pages to exclude from scroll position saving (supports wildcards)
    const excludedPagePatterns = [
        /\/catalog\.html$/i, // Exclude any page ending with /catalog.html (case-insensitive)
        // Add more patterns as needed
    ];

    // Function to check if current page matches any exclusion pattern
    function isExcludedPage(url) {
        return excludedPagePatterns.some(pattern => pattern.test(url));
    }

    // Function to save the scroll position for the current page
    function saveScrollPosition() {
        // Check if the current page matches any excluded pattern
        if (isExcludedPage(currentPage)) {
            return; // Skip saving scroll position for excluded pages
        }

        const scrollPosition = window.scrollY; // Get the current vertical scroll position
        localStorage.setItem(`scrollPosition_${currentPage}`, scrollPosition); // Store it in localStorage with a unique key

        // Manage the number of stored scroll positions
        manageScrollStorage();
    }

    // Function to restore the scroll position for the current page
    function restoreScrollPosition() {
        const savedPosition = localStorage.getItem(`scrollPosition_${currentPage}`); // Retrieve the saved position for the current page
        if (savedPosition) {
            window.scrollTo(0, parseInt(savedPosition, 10)); // Scroll to the saved position
        }
    }

    // Function to manage the number of stored scroll positions
    function manageScrollStorage() {
        const keys = Object.keys(localStorage).filter(key => key.startsWith('scrollPosition_'));

        // If the number of stored positions exceeds the limit, remove the oldest
        if (keys.length > MAX_PAGES) {
            // Sort keys by their creation time (assuming the order of keys reflects the order of storage)
            keys.sort((a, b) => {
                return localStorage.getItem(a) - localStorage.getItem(b);
            });
            // Remove the oldest entries until we are within the limit
            while (keys.length > MAX_PAGES) {
                localStorage.removeItem(keys.shift());
            }
        }
    }

    // Event listener to save scroll position before the page unloads
    window.addEventListener('beforeunload', saveScrollPosition);

    // Restore scroll position when the page loads
    window.addEventListener('load', restoreScrollPosition);

    /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    // Toggle Announcement & Posting Form
    // Create the button
    const button = document.createElement('button');
    button.style.margin = '10px';
    const postingFormDiv = document.getElementById('postingForm');
    const announcementDiv = document.getElementById('dynamicAnnouncement');
    const panelMessageDiv = document.getElementById('panelMessage');

    // Check if divs exist
    if (postingFormDiv && announcementDiv && panelMessageDiv) {
        // Insert the button before the announcement div
        postingFormDiv.parentNode.insertBefore(button, postingFormDiv);
        // Retrieve the visibility states from localStorage
        const isPostingFormVisible = localStorage.getItem('postingFormVisible') === 'true';
        const isAnnouncementVisible = localStorage.getItem('announcementVisible') === 'true';
        const isPanelMessageVisible = localStorage.getItem('panelMessageVisible') === 'true';
        // Set the initial state of the divs and button based on stored values
        if (isPostingFormVisible) {
            postingFormDiv.style.display = 'block'; // Show the posting div
        } else {
            postingFormDiv.style.display = 'none'; // Hide the posting div
        }
        if (isAnnouncementVisible) {
            announcementDiv.style.display = 'block'; // Show the announcement div
        } else {
            announcementDiv.style.display = 'none'; // Hide the announcement div
        }
        if (isPanelMessageVisible) {
            panelMessageDiv.style.display = 'block'; // Show the panel message div
        } else {
            panelMessageDiv.style.display = 'none'; // Hide the panel message div
        }
        // Update button text based on the visibility of the announcement div
        button.textContent = (isPostingFormVisible && isAnnouncementVisible && isPanelMessageVisible) ? '-' : '+';
        // Add click event listener to the button
        button.addEventListener('click', () => {
            // Toggle visibility of both divs
            const isCurrentlyVisible = postingFormDiv.style.display !== 'none' && announcementDiv.style.display !== 'none' && panelMessageDiv.style.display !== 'none';

            if (isCurrentlyVisible) {
                postingFormDiv.style.display = 'none'; // Hide the posting div
                announcementDiv.style.display = 'none'; // Hide the announcement div
                panelMessageDiv.style.display = 'none'; // Hide the panel message div
                button.textContent = '+'; // Change button text
                localStorage.setItem('postingFormVisible', 'false'); // Save state
                localStorage.setItem('announcementVisible', 'false'); // Save state
                localStorage.setItem('panelMessageVisible', 'false'); // Save state
            } else {
                postingFormDiv.style.display = 'block'; // Hide the posting div
                announcementDiv.style.display = 'block'; // Show the announcement div
                panelMessageDiv.style.display = 'block'; // Show the panel message div
                button.textContent = '-'; // Change button text
                localStorage.setItem('postingFormVisible', 'true'); // Save state
                localStorage.setItem('announcementVisible', 'true'); // Save state
                localStorage.setItem('panelMessageVisible', 'true'); // Save state
            }
        });
    }

    /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    // Keyboard Shortcuts
    //
    // QR (CTRL+Q)
    function toggleDiv(event) {
        // Check if Ctrl + Q is pressed
        if (event.ctrlKey && (event.key === 'q' || event.key === 'Q')) {
            const hiddenDiv = document.getElementById('quick-reply');
            // Toggle QR
            if (hiddenDiv.style.display === 'none' || hiddenDiv.style.display === '') {
                hiddenDiv.style.display = 'block'; // Show the div
            }
            else {
                hiddenDiv.style.display = 'none'; // Hide the div
            }
        }
    }
    // Add an event listener for keydown events
    document.addEventListener('keydown', toggleDiv);

    // Tags
    const bbCodeCombinations = new Map([
        ["s", ["[spoiler]", "[/spoiler]"]],
        ["b", ["'''", "'''"]],
        ["u", ["__", "__"]],
        ["i", ["''", "''"]],
        ["d", ["[doom]", "[/doom]"]],
        ["m", ["[moe]", "[/moe]"]],
        ["c", ["[code]", "[/code]"]],
    ]);

    function replyKeyboardShortcuts(ev) {
        // Only handle keydown events with ctrlKey pressed
        if (ev.ctrlKey) {
            const key = ev.key.toLowerCase();

            // Check if the key is a bbCode shortcut
            if (bbCodeCombinations.has(key)) {
                ev.preventDefault();
                const textBox = ev.target;
                const [openTag, closeTag] = bbCodeCombinations.get(key);
                const { selectionStart, selectionEnd, value } = textBox;

                if (selectionStart === selectionEnd) {
                    // No selection: insert empty tags and place cursor between them
                    const before = value.slice(0, selectionStart);
                    const after = value.slice(selectionEnd);
                    const newCursor = selectionStart + openTag.length;
                    textBox.value = before + openTag + closeTag + after;
                    textBox.selectionStart = textBox.selectionEnd = newCursor;
                } else {
                    // Replace selected text with tags around it
                    const before = value.slice(0, selectionStart);
                    const selected = value.slice(selectionStart, selectionEnd);
                    const after = value.slice(selectionEnd);
                    textBox.value = before + openTag + selected + closeTag + after;
                    // Keep selection around the newly wrapped text
                    textBox.selectionStart = selectionStart + openTag.length;
                    textBox.selectionEnd = selectionEnd + openTag.length;
                }
                return;
            }
        }
    }

    // Attach the handler
    document.getElementById("qrbody")?.addEventListener("keydown", replyKeyboardShortcuts);

    /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    // Catalog & Thread Image/Video Hover
    (function () {
        // Helper: Get full media src from thumbnail and filemime
        function getFullMediaSrcFromMime(thumbnailSrc, filemime) {
            if (!thumbnailSrc || !filemime) return null;
            // Remove "t_" from the filename
            let base = thumbnailSrc.replace(/\/t_/, '/');
            // Remove any extension if present
            base = base.replace(/\.(jpe?g|png|gif|webp|webm|mp4)$/i, '');
            // Map filemime to extension
            const mimeToExt = {
                'image/jpeg': '.jpg',
                'image/jpg': '.jpg',
                'image/png': '.png',
                'image/gif': '.gif',
                'image/webp': '.webp',
                'video/mp4': '.mp4',
                'video/webm': '.webm'
            };
            const ext = mimeToExt[filemime.toLowerCase()];
            if (!ext) return null;
            return base + ext;
        }

        // Track the floating media element
        let floatingMedia = null;
        let removeListener = null;

        // Mousemove handler to follow cursor and clamp to viewport
        function onMouseMove(event) {
            if (!floatingMedia) return;
            const viewportWidth = window.innerWidth;
            const viewportHeight = window.innerHeight;
            let mediaWidth = 0, mediaHeight = 0;
            if (floatingMedia.tagName === 'IMG') {
                mediaWidth = floatingMedia.naturalWidth || floatingMedia.width || floatingMedia.offsetWidth || 0;
                mediaHeight = floatingMedia.naturalHeight || floatingMedia.height || floatingMedia.offsetHeight || 0;
            } else if (floatingMedia.tagName === 'VIDEO') {
                mediaWidth = floatingMedia.videoWidth || floatingMedia.offsetWidth || 0;
                mediaHeight = floatingMedia.videoHeight || floatingMedia.offsetHeight || 0;
            }
            // Clamp to max 90vw/90vh
            mediaWidth = Math.min(mediaWidth, viewportWidth * 0.9);
            mediaHeight = Math.min(mediaHeight, viewportHeight * 0.9);

            let newX = event.clientX + 10;
            let newY = event.clientY + 10;
            // Clamp to viewport
            if (newX + mediaWidth > viewportWidth) {
                newX = viewportWidth - mediaWidth - 10;
            }
            if (newY + mediaHeight > viewportHeight) {
                newY = viewportHeight - mediaHeight - 10;
            }
            // Prevent negative positions
            newX = Math.max(newX, 0);
            newY = Math.max(newY, 0);

            // Double-check floatingMedia is still valid before using
            if (!floatingMedia) return;
            floatingMedia.style.left = `${newX}px`;
            floatingMedia.style.top = `${newY}px`;
            floatingMedia.style.maxWidth = '90vw';
            floatingMedia.style.maxHeight = '90vh';
        }

        // Remove the floating media and listeners
        function cleanupFloatingMedia() {
            // Remove event listener first to prevent race condition
            document.removeEventListener('mousemove', onMouseMove);
            if (floatingMedia && floatingMedia.parentNode) {
                floatingMedia.parentNode.removeChild(floatingMedia);
            }
            floatingMedia = null;
            if (removeListener) {
                removeListener();
                removeListener = null;
            }
        }

        // On thumbnail hover
        function onThumbEnter(e) {
            const thumb = e.currentTarget;
            // Find parent <a> with .linkThumb or .imgLink
            const parentA = thumb.closest('a.linkThumb, a.imgLink');
            if (!parentA) return;
            const filemime = parentA.getAttribute('data-filemime');
            const fullSrc = getFullMediaSrcFromMime(thumb.getAttribute('src'), filemime);
            if (!fullSrc) return;

            let loaded = false;

            function setCommonStyles(el) {
                el.style.position = 'fixed';
                el.style.zIndex = 9999;
                el.style.pointerEvents = 'none';
                el.style.maxWidth = '90vw';
                el.style.maxHeight = '90vh';
                el.style.transition = 'opacity 0.15s';
            }

            // Remove on mouseleave or scroll
            removeListener = function () {
                thumb.removeEventListener('mouseleave', cleanupFloatingMedia);
                window.removeEventListener('scroll', cleanupFloatingMedia, true);
            };
            thumb.addEventListener('mouseleave', cleanupFloatingMedia);
            window.addEventListener('scroll', cleanupFloatingMedia, true);

            // Create and load the media
            if (filemime.startsWith('image/')) {
                floatingMedia = document.createElement('img');
                setCommonStyles(floatingMedia);
                floatingMedia.style.opacity = '0';
                floatingMedia.style.left = '-9999px';

                document.addEventListener('mousemove', onMouseMove);
                onMouseMove(e);

                floatingMedia.onload = function () {
                    if (!loaded) {
                        loaded = true;
                        floatingMedia.style.opacity = '1';
                        document.body.appendChild(floatingMedia);
                        onMouseMove(e);
                    }
                };
                floatingMedia.onerror = cleanupFloatingMedia;
                floatingMedia.src = fullSrc;
            } else if (filemime.startsWith('video/')) {
                floatingMedia = document.createElement('video');
                setCommonStyles(floatingMedia);
                floatingMedia.style.opacity = '0';
                floatingMedia.style.left = '-9999px';
                floatingMedia.autoplay = true;
                floatingMedia.loop = true;
                floatingMedia.muted = false;
                floatingMedia.playsInline = true;

                document.addEventListener('mousemove', onMouseMove);
                onMouseMove(e);

                floatingMedia.onloadeddata = function () {
                    if (!loaded) {
                        loaded = true;
                        floatingMedia.style.opacity = '1';
                        document.body.appendChild(floatingMedia);
                        onMouseMove(e);
                    }
                };
                floatingMedia.onerror = cleanupFloatingMedia;
                floatingMedia.src = fullSrc;
            }
        }

        // Attach listeners to all current and future thumbnails
        function attachThumbListeners(root) {
            const thumbs = (root || document).querySelectorAll('a.linkThumb > img, a.imgLink > img');
            thumbs.forEach(thumb => {
                // Prevent duplicate listeners
                if (!thumb._fullImgHoverBound) {
                    thumb.addEventListener('mouseenter', onThumbEnter);
                    thumb._fullImgHoverBound = true;
                }
            });
        }

        // Initial attach
        attachThumbListeners();

        // Observe for dynamically added thumbnails
        const observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        attachThumbListeners(node);
                    }
                });
            });
        });
        observer.observe(document.body, { childList: true, subtree: true });
    })();
    /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    (function () {
        // Create the checkbox element
        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.id = 'saveNameCheckbox';
        checkbox.classList.add('postingCheckbox');

        // Create a label for the checkbox
        const label = document.createElement('label');
        label.htmlFor = 'saveNameCheckbox';
        label.textContent = 'Save Name';
        label.title = 'Save Name on refresh';

        // Find the element with the ID #qralwaysUseBypassCheckBox
        const alwaysUseBypassCheckbox = document.getElementById('qralwaysUseBypassCheckBox');
        if (alwaysUseBypassCheckbox) {
            // Append the checkbox first, then the label before the specified element
            alwaysUseBypassCheckbox.parentNode.insertBefore(checkbox, alwaysUseBypassCheckbox);
            alwaysUseBypassCheckbox.parentNode.insertBefore(label, checkbox.nextSibling);

            // Load the checkbox state from localStorage
            const savedCheckboxState = localStorage.getItem('saveNameCheckbox') === 'true';
            checkbox.checked = savedCheckboxState;

            // Find the name input field (adjust selector as needed)
            const nameInput = document.getElementById('qrname');
            if (nameInput) {
                // If the checkbox is checked and a name is saved, populate the input
                const savedName = localStorage.getItem('name');
                if (checkbox.checked && savedName !== null) {
                    nameInput.value = savedName;
                } else if (!checkbox.checked) {
                    // If checkbox is unchecked on page load, clear the name field
                    nameInput.value = '';
                }

                // Listen for changes to the name input and update localStorage if checkbox is checked
                nameInput.addEventListener('input', function () {
                    if (checkbox.checked) {
                        localStorage.setItem('name', nameInput.value);
                    }
                });

                // Event listener for checkbox change
                checkbox.addEventListener('change', function () {
                    if (checkbox.checked) {
                        // Save the current name input value to localStorage
                        localStorage.setItem('name', nameInput.value);
                    } else {
                        // Remove the item "name" from localStorage and clear the input field
                        localStorage.removeItem('name');
                        nameInput.value = '';
                    }
                    // Save the checkbox state in localStorage
                    localStorage.setItem('saveNameCheckbox', checkbox.checked);
                });
            }
        }
    })();

    /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    // Custom CSS injection
    function addCustomCSS(css) {
        if (!css) return;
        const style = document.createElement('style');
        style.type = 'text/css';
        style.appendChild(document.createTextNode(css));
        document.head.appendChild(style);
    }
    // Get the current URL path
    const currentPath = window.location.pathname.toLowerCase();
    const currentHost = window.location.hostname.toLowerCase();

    // Apply CSS based on URL pattern
    // Thread page CSS
    if (/\/res\/[^/]+\.html$/.test(currentPath)) {
        const css = `
/* Quick Reply */
#quick-reply {
display: block;
padding: 0 !important;
top: auto !important;
bottom: 0;
left: auto !important;
position: fixed;
right: 0 !important;
opacity: 0.7;
transition: opacity 0.3s ease;
}
#quick-reply:hover,
#quick-reply:focus-within {
opacity: 1;
}
#qrbody {
resize: vertical;
max-height: 50vh;
height: 130px;
}
.floatingMenu {
padding: 0 !important;
}
#qrFilesBody {
max-width: 300px;
}
/* Banner */
#bannerImage {
width: 305px;
right: 0;
position: fixed;
top: 26px;
}
.innerUtility.top {
margin-top: 2em;
background-color: transparent !important;
color: var(--link-color) !important;
}
.innerUtility.top a {
color: var(--link-color) !important;
}
/* Hover Posts */
img[style*="position: fixed"] {
max-width: 80vw;
max-height: 80vh !important;
z-index: 200;
}
.quoteTooltip {
z-index: 110;
}
/* (You) Replies */
.innerPost:has(.youName) {
border-left: solid #68b723 5px;
}
.innerPost:has(.quoteLink.you) {
border-left: solid #dd003e 5px;
}
/* Filename */
.originalNameLink {
display: inline;
overflow-wrap: anywhere;
white-space: normal;
}
`;
        addCustomCSS(css);
    }

    if (/^8chan\.(se|moe)$/.test(currentHost)) {
        // General CSS for all pages
        const css = `
/* Margins */
#mainPanel {
margin-left: 10px;
margin-right: 305px;
margin-top: 0;
margin-bottom: 0;
}
.innerPost {
margin-left: 40px;
display: block;
}
/* Cleanup */
#actionsForm,
#navTopBoardsSpan,
.coloredIcon.linkOverboard,
.coloredIcon.linkSfwOver,
.coloredIcon.multiboardButton,
#navLinkSpan>span:nth-child(9),
#navLinkSpan>span:nth-child(11),
#navLinkSpan>span:nth-child(13) {
display: none;
}
footer {
visibility: hidden;
height: 0;
}
/* Header */
#dynamicHeaderThread,
.navHeader {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
/* Thread Watcher */
#watchedMenu .floatingContainer {
min-width: 330px;
}
#watchedMenu .watchedCellLabel > a:after {
    content: " - "attr(href);
    filter: saturate(50%);
    font-style: italic;
    font-weight: bold;
}
#watchedMenu {
box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
}
/* Posts */
.quoteTooltip .innerPost {
overflow: hidden;
box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
}
`;
        addCustomCSS(css);
    }

    // Catalog page CSS
    if (/\/catalog\.html$/.test(currentPath)) {
        const css = `
#dynamicAnnouncement {
display: none;
}
#postingForm {
margin: 2em auto;
}
`;
        addCustomCSS(css);
    }
})();