// ==UserScript== // @name LynxChan Extended (8chan) // @namespace lynx.ext // @version 1.0.5 // @description Adds new functionality to LynxChan imageboards (mainly 8chan.moe/.se) // @author SaddestPanda // @license UNLICENSE // @match https://8chan.moe/* // @match https://8chan.se/* // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.registerMenuCommand // @run-at document-idle // @downloadURL none // ==/UserScript== (async function () { "use strict"; // Default settings const defaultSettings = { firstRun: true, showScrollbarMarkers: true, useThreadSpoilerImage: true, useAlternativeSpoilerImage: false, useExtraStylingFixes: true, }; const settings = { firstRun: await GM.getValue("firstRun", defaultSettings.firstRun), showScrollbarMarkers: await GM.getValue("showScrollbarMarkers", defaultSettings.showScrollbarMarkers), useThreadSpoilerImage: await GM.getValue("useThreadSpoilerImage", defaultSettings.useThreadSpoilerImage), useAlternativeSpoilerImage: await GM.getValue("useAlternativeSpoilerImage", defaultSettings.useAlternativeSpoilerImage), useExtraStylingFixes: await GM.getValue("useExtraStylingFixes", defaultSettings.useExtraStylingFixes), }; addMyStyle("lynx-extended-css", ` .marker-container { position: fixed; top: 16px; right: 0; width: 10px; height: calc(100vh - 35px); z-index: 11000; pointer-events: none; } .marker { position: absolute; width: 100%; height: 6px; background: #0092ff; cursor: pointer; pointer-events: auto; border-radius: 40% 0 0 40%; z-index: 5; } .marker.alt { background: #a8d8f8; z-index: 2; } #lynxExtendedMenu { position: fixed; top: 15px; right: 100px; padding: 10px; z-index: 10000; font-family: Arial, sans-serif; font-size: 14px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); background: #353535; border: 1px solid #737373; color: #ddd; border-radius: 4px; } `); // Register menu command GM.registerMenuCommand("Show Options Menu", openMenu); waitForElement("#navLinkSpan > .settingsButton", 50, () => { try { createSettingsButton(); } catch (error) { console.log("Error while creating settings button:", error); } }); function openMenu() { const oldMenu = document.getElementById("lynxExtendedMenu"); if (oldMenu) { oldMenu.remove(); return; } // Create options menu const menu = document.createElement("div"); menu.id = "lynxExtendedMenu"; menu.innerHTML = `

LynxChan Extended Options





(uses the first image of the first visible post on the current thread with the filename "ThreadSpoiler.jpg" (or .png or .webp))


(same as above with the filename "ThreadSpoilerAlt.jpg" (or .png or .webp))



`; document.body.appendChild(menu); // Save button functionality document.getElementById("saveSettings").addEventListener("click", async () => { settings.showScrollbarMarkers = document.getElementById("showScrollbarMarkers").checked; settings.useThreadSpoilerImage = document.getElementById("useThreadSpoilerImage").checked; settings.useAlternativeSpoilerImage = document.getElementById("useAlternativeSpoilerImage").checked; settings.useExtraStylingFixes = document.getElementById("useExtraStylingFixes").checked; await GM.setValue("showScrollbarMarkers", settings.showScrollbarMarkers); await GM.setValue("useThreadSpoilerImage", settings.useThreadSpoilerImage); await GM.setValue("useAlternativeSpoilerImage", settings.useAlternativeSpoilerImage); await GM.setValue("useExtraStylingFixes", settings.useExtraStylingFixes); alert("Settings saved!\nRefresh the page for the changes to take effect."); menu.remove(); }); // Close button functionality document.getElementById("closeMenu").addEventListener("click", () => { menu.remove(); }); } function createSettingsButton() { document.querySelector("#navLinkSpan > .settingsButton").insertAdjacentHTML("afterend", ` / `); document.querySelector("#navigation-lynxextended").addEventListener("click", openMenu); } /** * Waits for an element identified by a selector to appear in the DOM and executes the callback. * * @param {string} selector - The CSS selector of the element to wait for. * @param {number} delay - The time in milliseconds to wait before checking for the element again. * @param {function} callback - The callback function to execute once the element is found. */ function waitForElement(selector, delay, callback) { if (document.querySelector(selector)) { callback(); } else { setTimeout(() => waitForElement(selector, delay, callback), delay); } } function addMyStyle(newID, newStyle) { let myStyle = document.createElement("style"); //myStyle.type = 'text/css'; myStyle.id = newID; myStyle.textContent = newStyle; document.querySelector("head").appendChild(myStyle); } function createMarker(element, container, isReply) { const pageHeight = document.body.scrollHeight; const offsetTop = element.offsetTop; const percent = offsetTop / pageHeight; const marker = document.createElement("div"); marker.classList.add("marker"); if (isReply) { marker.classList.add("alt"); } marker.style.top = `${percent * 100}%`; marker.dataset.postid = element.id; marker.addEventListener("click", () => { let elem = element?.previousElementSibling || element; elem.scrollIntoView({ behavior: "smooth", block: "start" }); }); container.appendChild(marker); } function recreateScrollMarkers() { let oldContainer = document.querySelector(".marker-container"); if (oldContainer) { oldContainer.remove(); } // Create marker container const markerContainer = document.createElement("div"); if (settings.showScrollbarMarkers) { markerContainer.classList.add("marker-container"); document.body.appendChild(markerContainer); } // Match and create markers for "my posts" (matches native & dollchan) document.querySelectorAll(".postCell:has(.innerPost > .postInfo.title :is(.linkName.youName, .de-post-counter-you)),.postCell:has(.innerPost.de-mypost)") .forEach((elem) => { createMarker(elem, markerContainer, false); }); // Match and create markers for "replies" (matches native & dollchan) document.querySelectorAll(".postCell:has(.innerPost > .divMessage :is(.quoteLink.you, .de-ref-you)),.postCell:has(.innerPost.de-mypost-reply)") .forEach((elem) => { createMarker(elem, markerContainer, true); }); } // Function to fetch the thread spoiler image URL function getSpoilerUrl() { let spoilerImageUrl = null; if (settings.useThreadSpoilerImage) { const spoilerLink = Array.from(document.querySelectorAll('a.originalNameLink[download^="ThreadSpoiler"]')).find(link => /\.(jpg|png|webp)$/i.test(link.download)); spoilerImageUrl = spoilerLink ? spoilerLink.href : null; } if (settings.useAlternativeSpoilerImage) { const altSpoilerLink = Array.from(document.querySelectorAll('a.originalNameLink[download^="ThreadSpoilerAlt"]')).find(link => /\.(jpg|png|webp)$/i.test(link.download)); spoilerImageUrl = altSpoilerLink ? altSpoilerLink.href : null; } return spoilerImageUrl; } // Function to apply the thread spoiler image CSS function applySpoilerCss(spoilerImageUrl) { addMyStyle("thread-spoiler-css", ` .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]), .uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]) { background-image: url("${spoilerImageUrl}"); background-size: cover; outline: dashed 2px #ff0000f5; & > img { opacity: 0; } } `); } async function start() { console.log("%cLynx Extended: Started with settings:", "color:rgb(0, 140, 255)", settings); //Open the settings menu on the first run if (settings.firstRun) { settings.firstRun = false; await GM.setValue("firstRun", settings.firstRun); openMenu(); } if (settings.showScrollbarMarkers) { // Create markers 1 second after page load setTimeout(() => { recreateScrollMarkers(); }, 1000); //TODO LATER: why was mutation observer not working? let postCount = document.querySelectorAll("#threadList .postCell")?.length || 0; let interval = setInterval(() => { let newPostCount = document.querySelectorAll("#threadList .postCell")?.length || 0; if (newPostCount !== postCount) { postCount = newPostCount; recreateScrollMarkers(); } }, 500); } // Add functionality to apply the custom spoiler image CSS if (settings.useThreadSpoilerImage || settings.useAlternativeSpoilerImage) { let spoilerImageUrl = getSpoilerUrl(); if (spoilerImageUrl) { applySpoilerCss(spoilerImageUrl); } else { // Re-check every second if the spoiler image URL is blank const spoilerCheckInterval = setInterval(() => { spoilerImageUrl = getSpoilerUrl(); if (spoilerImageUrl) { clearInterval(spoilerCheckInterval); applySpoilerCss(spoilerImageUrl); } }, 1000); } } // Apply the CSS if the setting is enabled if (settings.useExtraStylingFixes) { addMyStyle("extra-styling-css", ` /* smaller thumbnails & image paddings */ body .uploadCell img:not(.imgExpanded) { max-width: 160px; max-height: 125px; object-fit: contain; height: auto; width: auto; margin-right: 0em; margin-bottom: 0em; } .uploadCell .imgLink { margin-right: 1.5em; } /* smaller post spacing (not too much) */ .divMessage { margin: .8em .8em .5em 3em; } .greenText { filter: brightness(110%); } /* mark your posts and replies (same selectors are also used for detection above) */ .postCell:has(.innerPost > .postInfo.title :is(.linkName.youName, .de-post-counter-you)), .postCell:has(.innerPost.de-mypost) { & > .innerPost { border-left: 3px dashed; border-left-color: #4BB2FFC2; padding-left: 0px; } } .postCell:has(.innerPost > .divMessage :is(.quoteLink.you, .de-ref-you)), .postCell:has(.innerPost.de-mypost-reply) { & > .innerPost { border-left: 2px solid; border-left-color: #a8d8f8b0; padding-left: 1px; } } `); } } // Wait for #threadList and then start waitForElement("#threadList", 50, start); })();