// ==UserScript==
// @name LynxChan Extended (8chan)
// @namespace lynx.ext
// @version 1.0.3
// @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),
};
console.log("%cLynx Extended: Started with settings:", "color:rgb(0, 140, 255)", settings);
addMyStyle("lynx-extended-css", `
.marker-container {
position: fixed;
top: 16px;
right: 0;
width: 10px;
height: calc(100vh - 40px);
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);
try {
createSettingsButton();
} catch (error) {
console.log("Error while creating settings button:", error);
}
//Open the settings menu on the first run
if (settings.firstRun) {
settings.firstRun = false;
await GM.setValue("firstRun", settings.firstRun);
openMenu();
}
// Create markers 1 second after page load
setTimeout(() => {
recreateScrollMarkers();
}, 1500);
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);
}
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);
});
}
if (settings.showScrollbarMarkers) {
// Add a second observer for #threadList (new posts)
const threadObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.classList && node.classList.contains("postCell")) {
// Recreate markers because the page grew taller. Is this heavy? probably not.
recreateScrollMarkers();
}
});
}
}
});
const threadList = document.querySelector("#threadList");
if (threadList) {
threadObserver.observe(threadList, { childList: 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;
}
}
`);
}
// 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;
}
}
`);
}
})();