// ==UserScript==
// @name 8chan Style Script
// @namespace 8chanSS
// @match *://8chan.moe/*
// @match *://8chan.se/*
// @exclude *://8chan.se/login.html
// @grant none
// @version 1.11
// @author Anon
// @run-at document-idle
// @description Script to style 8chan
// @license MIT
// ==/UserScript==
(async 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 <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 });
})();
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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);
}
})();