// ==UserScript== // @name 8chanSS // @version 1.2.27 // @namespace 8chanSS // @description Userscript to style 8chan // @minGMVer 4.3 // @minFFVer 121 // @license MIT; https://github.com/otacoo/8chanSS/blob/main/LICENSE // @match http://8chan.moe/* // @match https://8chan.moe/* // @match http://8chan.se/* // @match https://8chan.se/* // @exclude http://8chan.moe/login.html // @exclude https://8chan.moe/login.html // @exclude http://8chan.se/login.html // @exclude https://8chan.se/login.html // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.listValues // @run-at document-start // @downloadURL none // ==/UserScript== ///// JANK THEME FLASH FIX LOAD ASAP ///////// (function () { // Get the user's selected theme from localStorage const userTheme = localStorage.selectedTheme; if (!userTheme) return; // Try to swap the theme as early as possible const swapTheme = () => { // Find the for the board's theme const themeLink = Array.from( document.getElementsByTagName("link") ).find( (link) => link.rel === "stylesheet" && /\/\.static\/css\/themes\//.test(link.href) ); if (themeLink) { // Replace the href with the user's theme const themeBase = themeLink.href.replace(/\/[^\/]+\.css$/, "/"); themeLink.href = themeBase + userTheme + ".css"; } }; // Try immediately, and also on DOMContentLoaded in case elements aren't ready yet swapTheme(); document.addEventListener("DOMContentLoaded", swapTheme); // Also, if the theme selector exists, set its value to the user's theme document.addEventListener("DOMContentLoaded", function () { const themeSelector = document.getElementById("themeSelector"); if (themeSelector) { for (let i = 0; i < themeSelector.options.length; i++) { if ( themeSelector.options[i].value === userTheme || themeSelector.options[i].text === userTheme ) { themeSelector.selectedIndex = i; break; } } } }); })(); ////////// Disable native extension settings ////// (function () { try { // Image Hover localStorage.removeItem("hoveringImage"); } catch (e) { // Ignore errors (e.g., storage not available) } })(); ////////// ON READY HELPER /////////////////////// function onReady(fn) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", fn, { once: true }); } else { fn(); } } //////// START OF THE SCRIPT //////////////////// onReady(async function () { // --- Default Settings --- const scriptSettings = { site: { alwaysShowTW: { label: "Pin Thread Watcher", default: false }, enableHeaderCatalogLinks: { label: "Header Catalog Links", default: true, subOptions: { openInNewTab: { label: "Always open in new tab", default: false, }, }, }, enableBottomHeader: { label: "Bottom Header", default: false }, enableScrollSave: { label: "Save Scroll Position", default: true }, enableScrollArrows: { label: "Show Up/Down Arrows", default: false, }, hoverVideoVolume: { label: "Hover Media Volume (0-100%)", default: 50, type: "number", min: 0, max: 100, }, }, threads: { enableThreadImageHover: { label: "Thread Image Hover", default: true, }, watchThreadOnReply: { label: "Watch Thread on Reply", default: true, }, beepOnYou: { label: "Beep on (You)", default: false }, notifyOnYou: { label: "Notify when (You) (!)", default: true }, highlightOnYou: { label: "Highlight (You) posts", default: true }, hideHiddenPostStub: { label: "Hide Stubs of Hidden Posts", default: false, }, blurSpoilers: { label: "Blur Spoilers", default: false, subOptions: { removeSpoilers: { label: "Remove Spoilers", default: false, }, }, }, deleteSavedName: { label: "Delete Name Checkbox", default: true }, }, catalog: { enableCatalogImageHover: { label: "Catalog Image Hover", default: true, }, }, styling: { enableStickyQR: { label: "Enable Sticky Quick Reply", default: false, }, enableFitReplies: { label: "Fit Replies", default: false }, enableSidebar: { label: "Enable Sidebar", default: false }, hideAnnouncement: { label: "Hide Announcement", default: false }, hidePanelMessage: { label: "Hide Panel Message", default: false }, hidePostingForm: { label: "Hide Posting Form", default: false, subOptions: { showCatalogForm: { label: "Don't Hide in Catalog", default: false, }, }, }, hideBanner: { label: "Hide Board Banners", default: false }, }, }; // Flatten settings for backward compatibility with existing functions const flatSettings = {}; function flattenSettings() { Object.keys(scriptSettings).forEach((category) => { Object.keys(scriptSettings[category]).forEach((key) => { flatSettings[key] = scriptSettings[category][key]; // Also flatten any sub-options if (scriptSettings[category][key].subOptions) { Object.keys(scriptSettings[category][key].subOptions).forEach( (subKey) => { const fullKey = `${key}_${subKey}`; flatSettings[fullKey] = scriptSettings[category][key].subOptions[subKey]; } ); } }); }); } flattenSettings(); // --- GM storage wrappers --- async function getSetting(key) { if (!flatSettings[key]) { console.warn(`Setting key not found: ${key}`); return false; } let val = await GM.getValue("8chanSS_" + key, null); if (val === null) return flatSettings[key].default; if (flatSettings[key].type === "number") return Number(val); return val === "true"; } async function setSetting(key, value) { // Always store as string for consistency await GM.setValue("8chanSS_" + key, String(value)); } // --- Root CSS Class Toggles --- async function featureCssClassToggles() { document.documentElement.classList.add("8chanSS"); const classToggles = { enableFitReplies: "fit-replies", enableSidebar: "ss-sidebar", enableStickyQR: "sticky-qr", enableBottomHeader: "bottom-header", hideHiddenPostStub: "hide-stub", hideBanner: "disable-banner", hidePostingForm: "hide-posting-form", hidePostingForm_showCatalogForm: "show-catalog-form", hideAnnouncement: "hide-announcement", hidePanelMessage: "hide-panelmessage", highlightOnYou: "highlight-you", }; for (const [settingKey, className] of Object.entries(classToggles)) { if (await getSetting(settingKey)) { document.documentElement.classList.add(className); } else { document.documentElement.classList.remove(className); } } // URL-based class toggling const urlClassMap = [ { pattern: /\/catalog\.html$/i, className: "is-catalog" }, { pattern: /\/res\/[^/]+\.html$/i, className: "is-thread" }, { pattern: /^\/$/, className: "is-index" }, ]; const currentPath = window.location.pathname.toLowerCase(); urlClassMap.forEach(({ pattern, className }) => { if (pattern.test(currentPath)) { document.documentElement.classList.add(className); } else { document.documentElement.classList.remove(className); } }); } // Init featureCssClassToggles(); // --- Menu Icon --- const themeSelector = document.getElementById("themesBefore"); let link = null; let bracketSpan = null; if (themeSelector) { bracketSpan = document.createElement("span"); bracketSpan.textContent = "] [ "; link = document.createElement("a"); link.id = "8chanSS-icon"; link.href = "#"; link.textContent = "8chanSS"; link.style.fontWeight = "bold"; themeSelector.parentNode.insertBefore( bracketSpan, themeSelector.nextSibling ); themeSelector.parentNode.insertBefore(link, bracketSpan.nextSibling); } // --- Shortcuts tab --- function createShortcutsTab() { const container = document.createElement("div"); // Title const title = document.createElement("h3"); title.textContent = "Keyboard Shortcuts"; title.style.margin = "0 0 15px 0"; title.style.fontSize = "16px"; container.appendChild(title); // Shortcuts table const table = document.createElement("table"); table.style.width = "100%"; table.style.borderCollapse = "collapse"; // Table styles const tableStyles = { th: { textAlign: "left", padding: "8px 5px", borderBottom: "1px solid #444", fontSize: "14px", fontWeight: "bold", }, td: { padding: "8px 5px", borderBottom: "1px solid #333", fontSize: "13px", }, kbd: { background: "#333", border: "1px solid #555", borderRadius: "3px", padding: "2px 5px", fontSize: "12px", fontFamily: "monospace", }, }; // Create header row const headerRow = document.createElement("tr"); const shortcutHeader = document.createElement("th"); shortcutHeader.textContent = "Shortcut"; Object.assign(shortcutHeader.style, tableStyles.th); headerRow.appendChild(shortcutHeader); const actionHeader = document.createElement("th"); actionHeader.textContent = "Action"; Object.assign(actionHeader.style, tableStyles.th); headerRow.appendChild(actionHeader); table.appendChild(headerRow); // Shortcut data const shortcuts = [ { keys: ["Ctrl", "F1"], action: "Open 8chanSS settings" }, { keys: ["Ctrl", "Q"], action: "Toggle Quick Reply" }, { keys: ["Ctrl", "Enter"], action: "Submit post" }, { keys: ["Ctrl", "W"], action: "Watch Thread" }, { keys: ["Escape"], action: "Clear textarea and hide Quick Reply" }, { keys: ["Ctrl", "B"], action: "Bold text" }, { keys: ["Ctrl", "I"], action: "Italic text" }, { keys: ["Ctrl", "U"], action: "Underline text" }, { keys: ["Ctrl", "S"], action: "Spoiler text" }, { keys: ["Ctrl", "D"], action: "Doom text" }, { keys: ["Ctrl", "M"], action: "Moe text" }, { keys: ["Alt", "C"], action: "Code block" }, ]; // Create rows for each shortcut shortcuts.forEach((shortcut) => { const row = document.createElement("tr"); // Shortcut cell const shortcutCell = document.createElement("td"); Object.assign(shortcutCell.style, tableStyles.td); // Create kbd elements for each key shortcut.keys.forEach((key, index) => { const kbd = document.createElement("kbd"); kbd.textContent = key; Object.assign(kbd.style, tableStyles.kbd); shortcutCell.appendChild(kbd); // Add + between keys if (index < shortcut.keys.length - 1) { const plus = document.createTextNode(" + "); shortcutCell.appendChild(plus); } }); row.appendChild(shortcutCell); // Action cell const actionCell = document.createElement("td"); actionCell.textContent = shortcut.action; Object.assign(actionCell.style, tableStyles.td); row.appendChild(actionCell); table.appendChild(row); }); container.appendChild(table); // Add note about BBCode shortcuts const note = document.createElement("p"); note.textContent = "Text formatting shortcuts work when text is selected or when inserting at cursor position."; note.style.fontSize = "12px"; note.style.marginTop = "15px"; note.style.opacity = "0.7"; note.style.fontStyle = "italic"; container.appendChild(note); return container; } ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Custom CSS injection function addCustomCSS(css) { if (!css) return; const style = document.createElement("style"); style.type = "text/css"; style.id = "8chSS"; 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 if (/^8chan\.(se|moe)$/.test(currentHost)) { // General CSS for all pages const css = ` /* Margins */ :not(.is-catalog) body { margin: 0; } :root.ss-sidebar #mainPanel { margin-right: 305px; } /* Side Catalog */ #sideCatalogDiv { z-index: 200; background: var(--background-gradient); } /* Cleanup */ :root.hide-posting-form #postingForm, :root.hide-announcement #dynamicAnnouncement, :root.hide-panelmessage #panelMessage, #navFadeEnd, #navFadeMid, #navTopBoardsSpan { display: none; } :root.is-catalog.show-catalog-form #postingForm { display: block !important; } footer { visibility: hidden; height: 0; } /* Header */ nav.navHeader { z-index: 300; } :not(:root.bottom-header) .navHeader { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); } :root.bottom-header nav.navHeader { top: auto !important; bottom: 0 !important; box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.15); } /* Thread Watcher */ .watchButton.watched-active::before { color: #dd003e !important; } #watchedMenu { font-size: smaller; padding: 5px !important; box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19); } #watchedMenu, #watchedMenu .floatingContainer { min-width: 200px; } #watchedMenu .watchedCellLabel > a:after { content: " - "attr(href); filter: saturate(50%); font-style: italic; font-weight: bold; } #watchedMenu .watchedCellLabel > a::after { visibility: hidden; } td.watchedCell > label.watchedCellLabel { text-overflow: ellipsis; overflow: hidden; white-space: nowrap; width: 180px; display: block; } td.watchedCell > label.watchedCellLabel:hover { overflow: unset; width: auto; white-space: normal; } .watchedNotification::before { padding-right: 2px; } .scroll-arrow-btn { position: fixed; right: 50px; width: 36px; height: 35px; background: #222; color: #fff; border: none; border-radius: 50%; box-shadow: 0 2px 8px rgba(0,0,0,0.18); font-size: 22px; cursor: pointer; opacity: 0.7; z-index: 800; display: flex; align-items: center; justify-content: center; transition: opacity 0.2s, background 0.2s; } /* Up/Down Arrows */ :root.ss-sidebar .scroll-arrow-btn { right: 330px !important; } .scroll-arrow-btn:hover { opacity: 1; background: #444; } #scroll-arrow-up { bottom: 80px; } #scroll-arrow-down { bottom: 32px; } /* Links at top of page */ .innerUtility.top { margin-top: 2em; background-color: transparent !important; color: var(--link-color) !important; } .innerUtility.top a { color: var(--link-color) !important; } .bumpLockIndicator::after { padding-right: 3px; } `; addCustomCSS(css); } // Thread page CSS if (/\/res\/[^/]+\.html$/.test(currentPath)) { const css = ` /* Quick Reply */ :root.sticky-qr #quick-reply { display: block; top: auto !important; bottom: 0; left: auto !important; position: fixed; right: 0 !important; } :root.sticky-qr #qrbody { resize: vertical; max-height: 50vh; height: 130px; } #qrbody { min-width: 300px; } :root.bottom-header #quick-reply { bottom: 28px !important; } #quick-reply { padding: 0; opacity: 0.7; transition: opacity 0.3s ease; } #quick-reply:hover, #quick-reply:focus-within { opacity: 1; } .floatingMenu { padding: 0 !important; } #qrFilesBody { max-width: 300px; } /* Unread Line */ #unread-line { height: 2px; border: none !important; pointer-events: none !important; background-image: linear-gradient(to left, rgba(185, 185, 185, 0.2), var(--text-color), rgba(185, 185, 185, 0.2)); margin: -3px auto 0 auto; width: 60%; } /* Banner */ :root.disable-banner #bannerImage { display: none; } :root.ss-sidebar #bannerImage { width: 305px; right: 0; position: fixed; top: 26px; } :root.ss-sidebar.bottom-header #bannerImage { top: 0 !important; } .quoteTooltip { z-index: 999; } /* Posts */ .inlineQuote .replyPreview { margin-left: 20px; border-left: 1px solid #ccc; padding-left: 10px; } .nestedQuoteLink { text-decoration: underline dashed !important; } :root.hide-stub .unhideButton { display: none; } .quoteTooltip .innerPost { overflow: hidden; box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19); } :root.fit-replies :not(.hidden).innerPost { margin-left: 10px; display: flow-root; } :root.fit-replies .quoteTooltip { display: table !important; } /* (You) Replies */ :root.highlight-you .innerPost:has(.youName) { border-left: dashed #68b723 3px; } :root.highlight-you .innerPost:not(:has(.youName)):has(.quoteLink.you) { border-left: solid #dd003e 3px; } /* Filename & Thumbs */ .originalNameLink { display: inline; overflow-wrap: anywhere; white-space: normal; } .multipleUploads .uploadCell:not(.expandedCell) { max-width: 215px; } /* Not sure what this is about, guess we'll find out */ .postCell::before { display: inline !important; height: auto !important; } `; addCustomCSS(css); } // Catalog page CSS if (/\/catalog\.html$/.test(currentPath)) { const css = ` #dynamicAnnouncement { display: none; } #postingForm { margin: 2em auto; } `; addCustomCSS(css); } // --- Floating Settings Menu with Tabs --- async function createSettingsMenu() { let menu = document.getElementById("8chanSS-menu"); if (menu) return menu; menu = document.createElement("div"); menu.id = "8chanSS-menu"; menu.style.position = "fixed"; menu.style.top = "80px"; menu.style.left = "30px"; menu.style.zIndex = 99999; menu.style.background = "#222"; menu.style.color = "#fff"; menu.style.padding = "0"; menu.style.borderRadius = "8px"; menu.style.boxShadow = "0 4px 16px rgba(0,0,0,0.25)"; menu.style.display = "none"; menu.style.minWidth = "220px"; menu.style.width = "100%"; menu.style.maxWidth = "365px"; menu.style.fontFamily = "sans-serif"; menu.style.userSelect = "none"; // Draggable let isDragging = false, dragOffsetX = 0, dragOffsetY = 0; const header = document.createElement("div"); header.style.display = "flex"; header.style.justifyContent = "space-between"; header.style.alignItems = "center"; header.style.marginBottom = "0"; header.style.cursor = "move"; header.style.background = "#333"; header.style.padding = "5px 18px 5px"; header.style.borderTopLeftRadius = "8px"; header.style.borderTopRightRadius = "8px"; header.addEventListener("mousedown", function (e) { isDragging = true; const rect = menu.getBoundingClientRect(); dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top; document.body.style.userSelect = "none"; }); document.addEventListener("mousemove", function (e) { if (!isDragging) return; let newLeft = e.clientX - dragOffsetX; let newTop = e.clientY - dragOffsetY; const menuRect = menu.getBoundingClientRect(); const menuWidth = menuRect.width; const menuHeight = menuRect.height; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; newLeft = Math.max(0, Math.min(newLeft, viewportWidth - menuWidth)); newTop = Math.max(0, Math.min(newTop, viewportHeight - menuHeight)); menu.style.left = newLeft + "px"; menu.style.top = newTop + "px"; menu.style.right = "auto"; }); document.addEventListener("mouseup", function () { isDragging = false; document.body.style.userSelect = ""; }); // Title and close button const title = document.createElement("span"); title.textContent = "8chanSS Settings"; title.style.fontWeight = "bold"; header.appendChild(title); const closeBtn = document.createElement("button"); closeBtn.textContent = "✕"; closeBtn.style.background = "none"; closeBtn.style.border = "none"; closeBtn.style.color = "#fff"; closeBtn.style.fontSize = "18px"; closeBtn.style.cursor = "pointer"; closeBtn.style.marginLeft = "10px"; closeBtn.addEventListener("click", () => { menu.style.display = "none"; }); header.appendChild(closeBtn); menu.appendChild(header); // Tab navigation const tabNav = document.createElement("div"); tabNav.style.display = "flex"; tabNav.style.borderBottom = "1px solid #444"; tabNav.style.background = "#2a2a2a"; // Tab content container const tabContent = document.createElement("div"); tabContent.style.padding = "15px 18px"; tabContent.style.maxHeight = "60vh"; tabContent.style.overflowY = "auto"; // Store current (unsaved) values const tempSettings = {}; await Promise.all( Object.keys(flatSettings).map(async (key) => { tempSettings[key] = await getSetting(key); }) ); // Create tabs const tabs = { site: { label: "Site", content: createTabContent("site", tempSettings), }, threads: { label: "Threads", content: createTabContent("threads", tempSettings), }, catalog: { label: "Catalog", content: createTabContent("catalog", tempSettings), }, styling: { label: "Style", content: createTabContent("styling", tempSettings), }, shortcuts: { label: "⌨️", content: createShortcutsTab(), }, }; // Create tab buttons Object.keys(tabs).forEach((tabId, index, arr) => { const tab = tabs[tabId]; const tabButton = document.createElement("button"); tabButton.textContent = tab.label; tabButton.dataset.tab = tabId; tabButton.style.background = index === 0 ? "#333" : "transparent"; tabButton.style.border = "none"; tabButton.style.borderRight = "1px solid #444"; tabButton.style.color = "#fff"; tabButton.style.padding = "8px 15px"; tabButton.style.margin = "5px 0 0 0"; tabButton.style.cursor = "pointer"; tabButton.style.flex = "1"; tabButton.style.fontSize = "14px"; tabButton.style.transition = "background 0.2s"; // Add rounded corners and margin to the first and last tab if (index === 0) { tabButton.style.borderTopLeftRadius = "8px"; tabButton.style.margin = "5px 0 0 5px"; } if (index === arr.length - 1) { tabButton.style.borderTopRightRadius = "8px"; tabButton.style.margin = "5px 5px 0 0"; tabButton.style.borderRight = "none"; // Remove border on last tab } tabButton.addEventListener("click", () => { // Hide all tab contents Object.values(tabs).forEach((t) => { t.content.style.display = "none"; }); // Show selected tab content tab.content.style.display = "block"; // Update active tab button tabNav.querySelectorAll("button").forEach((btn) => { btn.style.background = "transparent"; }); tabButton.style.background = "#333"; }); tabNav.appendChild(tabButton); }); menu.appendChild(tabNav); // Add all tab contents to the container Object.values(tabs).forEach((tab, index) => { tab.content.style.display = index === 0 ? "block" : "none"; tabContent.appendChild(tab.content); }); menu.appendChild(tabContent); // Button container for Save and Reset buttons const buttonContainer = document.createElement("div"); buttonContainer.style.display = "flex"; buttonContainer.style.gap = "10px"; buttonContainer.style.padding = "0 18px 15px"; // Save Button const saveBtn = document.createElement("button"); saveBtn.textContent = "Save"; saveBtn.style.background = "#4caf50"; saveBtn.style.color = "#fff"; saveBtn.style.border = "none"; saveBtn.style.borderRadius = "4px"; saveBtn.style.padding = "8px 18px"; saveBtn.style.fontSize = "15px"; saveBtn.style.cursor = "pointer"; saveBtn.style.flex = "1"; saveBtn.addEventListener("click", async function () { for (const key of Object.keys(tempSettings)) { await setSetting(key, tempSettings[key]); } saveBtn.textContent = "Saved!"; setTimeout(() => { saveBtn.textContent = "Save"; }, 900); setTimeout(() => { window.location.reload(); }, 400); }); buttonContainer.appendChild(saveBtn); // Reset Button const resetBtn = document.createElement("button"); resetBtn.textContent = "Reset"; resetBtn.style.background = "#dd3333"; resetBtn.style.color = "#fff"; resetBtn.style.border = "none"; resetBtn.style.borderRadius = "4px"; resetBtn.style.padding = "8px 18px"; resetBtn.style.fontSize = "15px"; resetBtn.style.cursor = "pointer"; resetBtn.style.flex = "1"; resetBtn.addEventListener("click", async function () { if (confirm("Reset all 8chanSS settings to defaults?")) { // Remove all 8chanSS_ GM values const keys = await GM.listValues(); for (const key of keys) { if (key.startsWith("8chanSS_")) { await GM.deleteValue(key); } } resetBtn.textContent = "Reset!"; setTimeout(() => { resetBtn.textContent = "Reset"; }, 900); setTimeout(() => { window.location.reload(); }, 400); } }); buttonContainer.appendChild(resetBtn); menu.appendChild(buttonContainer); // Info const info = document.createElement("div"); info.style.fontSize = "11px"; info.style.padding = "0 18px 12px"; info.style.opacity = "0.7"; info.style.textAlign = "center"; info.textContent = "Press Save to apply changes. Page will reload."; menu.appendChild(info); document.body.appendChild(menu); return menu; } // Helper function to create tab content function createTabContent(category, tempSettings) { const container = document.createElement("div"); const categorySettings = scriptSettings[category]; Object.keys(categorySettings).forEach((key) => { const setting = categorySettings[key]; // Parent row: flex for checkbox, label, chevron const parentRow = document.createElement("div"); parentRow.style.display = "flex"; parentRow.style.alignItems = "center"; parentRow.style.marginBottom = "0px"; // Special case: hoverVideoVolume slider if (key === "hoverVideoVolume" && setting.type === "number") { const label = document.createElement("label"); label.htmlFor = "setting_" + key; label.textContent = setting.label + ": "; label.style.flex = "1"; const sliderContainer = document.createElement("div"); sliderContainer.style.display = "flex"; sliderContainer.style.alignItems = "center"; sliderContainer.style.flex = "1"; const slider = document.createElement("input"); slider.type = "range"; slider.id = "setting_" + key; slider.min = setting.min; slider.max = setting.max; slider.value = Number(tempSettings[key]); slider.style.flex = "unset"; slider.style.width = "100px"; slider.style.marginRight = "10px"; const valueLabel = document.createElement("span"); valueLabel.textContent = slider.value + "%"; valueLabel.style.minWidth = "40px"; valueLabel.style.textAlign = "right"; slider.addEventListener("input", function () { let val = Number(slider.value); if (isNaN(val)) val = setting.default; val = Math.max(setting.min, Math.min(setting.max, val)); slider.value = val; tempSettings[key] = val; valueLabel.textContent = val + "%"; }); sliderContainer.appendChild(slider); sliderContainer.appendChild(valueLabel); parentRow.appendChild(label); parentRow.appendChild(sliderContainer); // Wrapper for parent row and sub-options const wrapper = document.createElement("div"); wrapper.style.marginBottom = "10px"; wrapper.appendChild(parentRow); container.appendChild(wrapper); return; // Skip the rest for this key } // Checkbox for boolean settings const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.id = "setting_" + key; checkbox.checked = tempSettings[key] === true || tempSettings[key] === "true"; checkbox.style.marginRight = "8px"; // Label const label = document.createElement("label"); label.htmlFor = checkbox.id; label.textContent = setting.label; label.style.flex = "1"; // Chevron for subOptions let chevron = null; let subOptionsContainer = null; if (setting.subOptions) { chevron = document.createElement("span"); chevron.className = "ss-chevron"; chevron.innerHTML = "▶"; // Right-pointing triangle chevron.style.display = "inline-block"; chevron.style.transition = "transform 0.2s"; chevron.style.marginLeft = "6px"; chevron.style.fontSize = "12px"; chevron.style.userSelect = "none"; chevron.style.transform = checkbox.checked ? "rotate(90deg)" : "rotate(0deg)"; } // Checkbox change handler checkbox.addEventListener("change", function () { tempSettings[key] = checkbox.checked; if (setting.subOptions && subOptionsContainer) { subOptionsContainer.style.display = checkbox.checked ? "block" : "none"; if (chevron) { chevron.style.transform = checkbox.checked ? "rotate(90deg)" : "rotate(0deg)"; } } }); parentRow.appendChild(checkbox); parentRow.appendChild(label); if (chevron) parentRow.appendChild(chevron); // Wrapper for parent row and sub-options const wrapper = document.createElement("div"); wrapper.style.marginBottom = "10px"; wrapper.appendChild(parentRow); // Handle sub-options if any exist if (setting.subOptions) { subOptionsContainer = document.createElement("div"); subOptionsContainer.style.marginLeft = "25px"; subOptionsContainer.style.marginTop = "5px"; subOptionsContainer.style.display = checkbox.checked ? "block" : "none"; Object.keys(setting.subOptions).forEach((subKey) => { const subSetting = setting.subOptions[subKey]; const fullKey = `${key}_${subKey}`; const subWrapper = document.createElement("div"); subWrapper.style.marginBottom = "5px"; const subCheckbox = document.createElement("input"); subCheckbox.type = "checkbox"; subCheckbox.id = "setting_" + fullKey; subCheckbox.checked = tempSettings[fullKey]; subCheckbox.style.marginRight = "8px"; subCheckbox.addEventListener("change", function () { tempSettings[fullKey] = subCheckbox.checked; }); const subLabel = document.createElement("label"); subLabel.htmlFor = subCheckbox.id; subLabel.textContent = subSetting.label; subWrapper.appendChild(subCheckbox); subWrapper.appendChild(subLabel); subOptionsContainer.appendChild(subWrapper); }); wrapper.appendChild(subOptionsContainer); } container.appendChild(wrapper); }); // Add minimal CSS for chevron (only once) if (!document.getElementById("ss-chevron-style")) { const style = document.createElement("style"); style.id = "ss-chevron-style"; style.textContent = ` .ss-chevron { transition: transform 0.2s; margin-left: 6px; font-size: 12px; display: inline-block; } `; document.head.appendChild(style); } return container; } // Hook up the icon to open/close the menu if (link) { let menu = await createSettingsMenu(); link.style.cursor = "pointer"; link.title = "Open 8chanSS settings"; link.addEventListener("click", async function (e) { e.preventDefault(); let menu = await createSettingsMenu(); menu.style.display = menu.style.display === "none" ? "block" : "none"; }); } /* --- Scroll Arrows Feature --- */ function featureScrollArrows() { // Only add once if ( document.getElementById("scroll-arrow-up") || document.getElementById("scroll-arrow-down") ) return; // Up arrow const upBtn = document.createElement("button"); upBtn.id = "scroll-arrow-up"; upBtn.className = "scroll-arrow-btn"; upBtn.title = "Scroll to top"; upBtn.innerHTML = "▲"; upBtn.addEventListener("click", () => { window.scrollTo({ top: 0, behavior: "smooth" }); }); // Down arrow const downBtn = document.createElement("button"); downBtn.id = "scroll-arrow-down"; downBtn.className = "scroll-arrow-btn"; downBtn.title = "Scroll to bottom"; downBtn.innerHTML = "▼"; downBtn.addEventListener("click", () => { const footer = document.getElementById("footer"); if (footer) { footer.scrollIntoView({ behavior: "smooth", block: "end" }); } else { window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth", }); } }); document.body.appendChild(upBtn); document.body.appendChild(downBtn); } // --- Feature: Header Catalog Links --- async function featureHeaderCatalogLinks() { async function appendCatalogToLinks() { const navboardsSpan = document.getElementById("navBoardsSpan"); if (navboardsSpan) { const links = navboardsSpan.getElementsByTagName("a"); const openInNewTab = await getSetting( "enableHeaderCatalogLinks_openInNewTab" ); for (let link of links) { if (link.href && !link.href.endsWith("/catalog.html")) { link.href += "/catalog.html"; // Set target="_blank" if the option is enabled if (openInNewTab) { link.target = "_blank"; link.rel = "noopener noreferrer"; // Security best practice } else { link.target = ""; link.rel = ""; } } } } } appendCatalogToLinks(); const observer = new MutationObserver(appendCatalogToLinks); const config = { childList: true, subtree: true }; const navboardsSpan = document.getElementById("navBoardsSpan"); if (navboardsSpan) { observer.observe(navboardsSpan, config); } } // --- Feature: Save Scroll Position (now with unread line) --- async function featureSaveScroll() { // Return early if root has .is-index if (document.documentElement.classList.contains("is-index")) return; const MAX_PAGES = 50; const currentPage = window.location.href; const excludedPagePatterns = [ /\/catalog\.html$/i, /\/.media\/$/i, /\/boards\.js$/i, /\/login\.html$/i, /\/overboard$/i, /\/sfw$/i ]; function isExcludedPage(url) { return excludedPagePatterns.some((pattern) => pattern.test(url)); } async function saveScrollPosition() { if (isExcludedPage(currentPage)) return; const scrollPosition = window.scrollY; const timestamp = Date.now(); // Store both the scroll position and timestamp using GM storage await GM.setValue( `8chanSS_scrollPosition_${currentPage}`, JSON.stringify({ position: scrollPosition, timestamp: timestamp, }) ); await manageScrollStorage(); } async function manageScrollStorage() { // Get all GM storage keys const allKeys = await GM.listValues(); // Filter for scroll position keys const scrollKeys = allKeys.filter((key) => key.startsWith("8chanSS_scrollPosition_") ); if (scrollKeys.length > MAX_PAGES) { // Create array of objects with key and timestamp const keyData = await Promise.all( scrollKeys.map(async (key) => { let data; try { const savedValue = await GM.getValue(key, null); data = savedValue ? JSON.parse(savedValue) : { position: 0, timestamp: 0 }; } catch (e) { data = { position: 0, timestamp: 0 }; } return { key: key, timestamp: data.timestamp || 0, }; }) ); // Sort by timestamp (oldest first) keyData.sort((a, b) => a.timestamp - b.timestamp); // Remove oldest entries until we're under the limit const keysToRemove = keyData.slice(0, keyData.length - MAX_PAGES); for (const item of keysToRemove) { await GM.deleteValue(item.key); } } } async function addUnreadLine() { // If the URL contains a hash (e.g. /res/1190.html#1534), do nothing if (window.location.hash && window.location.hash.length > 1) { return; } const savedData = await GM.getValue( `8chanSS_scrollPosition_${currentPage}`, null ); if (savedData) { let position; try { // Try to parse as JSON (new format) const data = JSON.parse(savedData); position = data.position; // Update the timestamp to "refresh" this entry await GM.setValue( `8chanSS_scrollPosition_${currentPage}`, JSON.stringify({ position: position, timestamp: Date.now(), }) ); } catch (e) { // If parsing fails, skip (should not happen with cleaned storage) return; } if (!isNaN(position)) { window.scrollTo(0, position); // Only add unread-line if a saved position exists (i.e., not first visit) setTimeout(addUnreadLineAtViewportCenter, 100); } } } //---- Add an unread-line marker after the .postCell