您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds new functionality to Meguca/shamichan imageboards
// ==UserScript== // @name Meguca Extended // @namespace meguca.shamichan.ext // @version 1.1.2 // @description Adds new functionality to Meguca/shamichan imageboards // @author SaddestPanda // @license UNLICENSE // @match https://2chen.moe/* // @match https://sturdychan.help/* // @match https://shamik.ooo/* // @match https://shamiko.org/* // @match https://meta.4chan.gay/* // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.registerMenuCommand // @run-at document-idle // ==/UserScript== (async function () { "use strict"; // Default settings const defaultSettings = { scrollToUnread: true, showScrollbarMarkers: true, firstRun: true, }; const settings = { scrollToUnread: await GM.getValue("scrollToUnread", defaultSettings.scrollToUnread), showScrollbarMarkers: await GM.getValue("showScrollbarMarkers", defaultSettings.showScrollbarMarkers), firstRun: await GM.getValue("firstRun", defaultSettings.firstRun), }; console.log("%cMeguca Extended: Started with settings:", "color:rgb(0, 140, 255)", settings); addMyStyle("meguca-extended-css", ` .lastRead { border-top: 8px solid #1cb9d2; } .marker-container { position: fixed; top: 0; right: 0; width: 10px; height: 100vh; z-index: 1000; 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; } #megucaExtendedMenu { 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; } .postMine { border-left: 3px dashed; border-left-color: #36a9ffed !important; padding-left: 7px; box-sizing: border-box; } .postReply:not(.postMine) { border-left: 2px solid; border-left-color: #a8d8f8b0 !important; padding-left: 8px; box-sizing: border-box; } `); // 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(); } let threadID = document.querySelector("#thread-container")?.dataset.id || null; if (!threadID) { console.log("Meguca Extended: Thread ID is empty. Is this even a thread? Exiting."); return; } let threadPosts = document.querySelectorAll("#threads #thread-container article"); // if (threadPosts?.length < 10) { // //disable if there are less than 10 posts // return; // } const yourPosts = []; const yourReplies = []; let db; let retries = 0; dbStart(); function openMenu() { const oldMenu = document.getElementById("megucaExtendedMenu"); if (oldMenu) { oldMenu.remove(); return; } // Create options menu const menu = document.createElement("div"); menu.id = "megucaExtendedMenu"; menu.innerHTML = ` <h3 style="text-align: center; color:#6bc9ff;">Meguca Extended Options</h3><br><br> <label> <input type="checkbox" id="scrollToUnread" ${settings.scrollToUnread ? "checked" : ""}> Scroll to first unread post after page load </label><br> <label> <input type="checkbox" id="showScrollbarMarkers" ${settings.showScrollbarMarkers ? "checked" : ""}> Show your posts and replies on the scrollbar </label><br><br> <button id="saveSettings">Save</button> <button id="closeMenu">Close</button> `; document.body.appendChild(menu); // Save button functionality document.getElementById("saveSettings").addEventListener("click", async () => { settings.scrollToUnread = document.getElementById("scrollToUnread").checked; settings.showScrollbarMarkers = document.getElementById("showScrollbarMarkers").checked; await GM.setValue("scrollToUnread", settings.scrollToUnread); await GM.setValue("showScrollbarMarkers", settings.showScrollbarMarkers); 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(".overlay-container #banner-watcher").parentElement.insertAdjacentHTML("beforeend", ` <a id="banner-megucaextended" class="banner-float svg-link noscript-hide" title="Meguca Extended Settings" style="color: #2eb1ff;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <path d="M352 320c88.4 0 160-71.6 160-160c0-15.3-2.2-30.1-6.2-44.2c-3.1-10.8-16.4-13.2-24.3-5.3l-76.8 76.8c-3 3-7.1 4.7-11.3 4.7L336 192c-8.8 0-16-7.2-16-16l0-57.4c0-4.2 1.7-8.3 4.7-11.3l76.8-76.8c7.9-7.9 5.4-21.2-5.3-24.3C382.1 2.2 367.3 0 352 0C263.6 0 192 71.6 192 160c0 19.1 3.4 37.5 9.5 54.5L19.9 396.1C7.2 408.8 0 426.1 0 444.1C0 481.6 30.4 512 67.9 512c18 0 35.3-7.2 48-19.9L297.5 310.5c17 6.2 35.4 9.5 54.5 9.5zM80 408a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"> </path> </svg> </a> `); document.querySelector("#banner-megucaextended").addEventListener("click", openMenu); } function dbStart() { let indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB; let DBOpenRequest = indexedDB.open("meguca"); DBOpenRequest.onsuccess = (event) => { db = event.target.result; dbContinue(); }; DBOpenRequest.onerror = (event) => { //retry db access if (retries < 5) { retries++; setTimeout(() => { dbStart(); }, 150); } }; } async function dbContinue() { //Always mark unread, the setting is just for the scrolling part. let transaction = db.transaction("seenPost", "readonly"); let objectStore = transaction.objectStore("seenPost"); let getAll = objectStore.getAll(); getAll.onsuccess = (event) => { let allData = event.target.result; //Find "first unread post" let postIDs = new Map(); threadPosts.forEach((element) => { let id = parseInt(element.id.split("p")[1]); postIDs.set(id, true); }); allData.forEach((obj) => { if (obj.op == threadID) { postIDs.delete(obj.id); } }); if (postIDs.size == 0) { //No unread posts. Scroll to bottom. document.querySelector("html").scrollIntoView(false); } else { //Scroll to first unread post const iterator = postIDs.keys(); let firstUnreadID = iterator.next().value; let firstUnreadElem = document.querySelector(`article[id="p${firstUnreadID}"]`); if (firstUnreadElem) { //Mark as read (add styling) firstUnreadElem.classList.add("lastRead"); //Do scroll (top of next elem) if (settings.scrollToUnread) { let firstUnreadPos = findPos(firstUnreadElem?.nextElementSibling || firstUnreadElem); window.scroll(0, firstUnreadPos.top - window.innerHeight); } } } /* //Find "last read post" //This method doesn't work as hovered backlinks are set to read as well let filteredData = allData.filter(obj => obj.op == threadID); let lastObj = filteredData[filteredData.length - 1]; let lastReadElem = document.querySelector(`article[id="p${lastObj.id}"]`); //Mark as read (add styling) lastReadElem.classList.add("lastRead"); //Scroll one screen height above last read (don't show last read) let lastReadPos = findPos(lastReadElem); window.scroll(0, lastReadPos.top - window.innerHeight + 150); //+N is to show last read post and part of the next post */ }; getAll.onerror = (event) => { console.error("Meguca Extended: Error accessing 'seenPost' object store:", event); db.close(); }; //Always mark posts, the setting is just for the scrollbar marks let mineTransaction = db.transaction("mine", "readonly"); let mineObjectStore = mineTransaction.objectStore("mine"); let getAllMine = mineObjectStore.getAll(); getAllMine.onsuccess = (mineEvent) => { //Close db early db.close(); let mineData = mineEvent.target.result; // Create marker container const markerContainer = document.createElement("div"); if (settings.showScrollbarMarkers) { markerContainer.classList.add("marker-container"); document.body.appendChild(markerContainer); } // Filter and log matching "op" values mineData.forEach((obj) => { if (obj.op == threadID) { let postMine = document.querySelector(`article[id="p${obj.id}"]`); if (postMine) { postMine.classList.add("postMine"); yourPosts.push(obj.id); let postReplyLinks = postMine.querySelectorAll(".backlinks a[data-id]"); postReplyLinks.forEach((link) => { let postReply = document.querySelector(`article[id="p${link.dataset.id}"]`); if (postReply) { yourReplies.push(link.dataset.id); postReply.classList.add("postReply"); } }); } } }); if (settings.showScrollbarMarkers) { recreateScrollMarkers(); } }; getAllMine.onerror = (mineEvent) => { console.error("Meguca Extended: Error accessing 'mine' object store:", mineEvent); }; } 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 findPos(obj) { const rect = obj.getBoundingClientRect(); return { left: rect.left + window.scrollX, top: rect.top + window.scrollY, }; } 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}vh`; 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); } yourPosts.forEach((id) => { const elem = document.querySelector(`article[id="p${id}"]`); if (elem) { createMarker(elem, markerContainer, false); } }); yourReplies.forEach((id) => { const elem = document.querySelector(`article[id="p${id}"]`); if (elem) { createMarker(elem, markerContainer, true); } }); } //Observe changes to #hover-overlay to add the styles (hovered posts) const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { mutation.addedNodes.forEach((node) => { if (node.tagName === "ARTICLE") { const postID = node.id.slice(1); if (yourPosts.some((id) => id === postID)) { node.classList.add("postMine"); } else if (yourReplies.some((id) => id === postID)) { node.classList.add("postReply"); } } }); } } }); const hoverDiv = document.querySelector("#hover-overlay"); observer.observe(hoverDiv, { childList: true, subtree: true }); // Add a second observer for #thread-container (new posts) const threadObserver = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { mutation.addedNodes.forEach((node) => { if (node.tagName === "ARTICLE") { checkPost(node); //Recreat markers because the page grew taller. Is this heavy? probably not. if (settings.showScrollbarMarkers) { recreateScrollMarkers(); } } }); } } }); function checkPost(node) { const youIndicator = node.querySelector("header i"); if (youIndicator && youIndicator.textContent.match(/\(you\)/i)) { node.classList.add("postMine"); const postID = node.id.slice(1); yourPosts.push(postID); } const postLink = node.querySelector(".post-container a.post-link"); if (postLink && postLink.textContent.match(/\(you\)/i)) { //Can also match the ids from yourPosts node.classList.add("postReply"); const postID = node.id.slice(1); yourReplies.push(postID); } if (node.classList.contains("editing")) { //Recheck until each post finishes editing (slowly) const editpost = node; //this is necessary let checkInterval = setInterval(() => { if (!editpost.classList.contains("editing")) { clearInterval(checkInterval); //wait for the post to settle down (waiting for the links to be created) setTimeout(() => { checkPost(editpost); if (settings.showScrollbarMarkers) { recreateScrollMarkers(); } }, 1200); } }, 100); } } const threadContainer = document.querySelector("#thread-container"); if (threadContainer) { threadObserver.observe(threadContainer, { childList: true }); } })();