// ==UserScript== // @name 8chan Style Script // @namespace 8chanSS // @match *://8chan.moe/*/res/* // @match *://8chan.se/*/res/* // @match *://8chan.cc/*/res/* // @match *://8chan.moe/*/catalog.html // @match *://8chan.se/*/catalog.html // @match *://8chan.cc/*/catalog.html // @grant none // @version 1.13 // @author Anon // @run-at document-idle // @description Script to style 8chan // @license MIT // @downloadURL none // ==/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) { const key = ev.key.toLowerCase(); // Special case: alt+c for [code] tag if (key === "c" && ev.altKey && !ev.ctrlKey && 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; } // All other tags: ctrl+key if (ev.ctrlKey && !ev.altKey && bbCodeCombinations.has(key) && key !== "c") { 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 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 }); })(); ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Save Name checkbox (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); } })();