您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
LynxChan Extended with even more features
- // ==UserScript==
- // @name LynxChan Extended Minus Minus
- // @namespace https://rentry.org/8chanMinusMinus
- // @version 2.3.13
- // @description LynxChan Extended with even more features
- // @author SaddestPanda & Dandelion & /gfg/
- // @license UNLICENSE
- // @match *://8chan.moe/*
- // @match *://8chan.se/*
- // @match *://8chan.cc/*
- // @grant GM.getValue
- // @grant GM.setValue
- // @grant GM.deleteValue
- // @grant GM.registerMenuCommand
- // @run-at document-start
- // ==/UserScript==
- //TODO LATER MAYBE: combine all CSS into one <style> and use classes on html or body instead.
- (async function () {
- "use strict";
- const REGEX_THREAD = /\/res|last\//;
- const SETTINGS_DEFINITIONS = {
- firstRun:{
- default:true,
- hidden:true,
- desc:"You shouldn't be able to see this setting! (firstRun)"
- },
- addKeyboardHandlers:{
- default:true,
- desc:"Add keyboard Ctrl+ hotkeys to the quick reply box (Disable this for 8chanSS compatibility)"
- },
- showScrollbarMarkers:{
- default:true,
- type:"checkbox_with_colors",
- desc:"Show your posts and replies on the scrollbar",
- color1Default:"#0092ff",
- color1Desc:"<b>Your marker:</b>",
- color2Default:"#a8d8f8",
- color2Desc:"<b>Reply marker:</b>"
- },
- spoilerImageType:{
- default:"off",
- desc:"Override how the spoiler thumbnail looks:",
- type:"radio",
- options:{
- off:"Don't change the thumbnail.",
- reveal:"Reveal spoilers <span class='altText lineBefore'>(Previously spoilered images will have a red border around them indicating that they're spoilers.)</span>",
- reveal_blur:"Change to a blurred thumbnail <span class='altText lineBefore'>(Unblurred when you hover your mouse over.)</span>",
- kachina:"Makes the spoiler image Kachina from Genshin Impact.",
- thread:`<b>Use <b style="color: var(--link-color);">"ThreadSpoiler.jpg"</b> from the current thread <span class="altText lineBefore">(first posted jpg, png or webp image with that filename)</span></b>`,
- threadAlt:`same as above with the filename <b style="color: var(--link-color);">"ThreadSpoilerAlt.jpg"</b> <span class="altText lineBefore">(jpg, png or webp; uses ThreadSpoiler.jpg until this is found)</span>`,
- //test:`[TEST OPTION] Set custom spoiler thumb per-thread (For /gacha/ only!)`
- },
- nonewline:true
- },
- overrideBoardSpoilerImage: {
- default:true,
- parent:"spoilerImageType",
- //Not implemented yet
- //depends: function() {return settings.spoilerImageType != "off"},
- desc:"Also override board's custom thumbnail image <span class='altText lineBefore'>(for example, /v/'s spoiler thumbnail is an image of a monitor with a ? inside it)</span>"
- },
- revealSpoilerText:{
- default:"off",
- desc:"Reveal the spoiler text. Or make it into madoka runes.",
- type:"radio",
- options:{
- off:"Don't reveal spoilers.",
- on:"Spoilers will be always be shown by turning the text white.",
- madoka:`Spoilers will turn into madoka runes. Please install <a href="https://www.dropbox.com/s/n6ys414nviitr9y/MadokaRunes-2.0.ttf"><u>MadokaRunes.ttf</u></a> for it to show up properly.`
- }
- },
- markPostEdge:{
- default:true,
- type:"checkbox_with_colors",
- desc:"<span class='boldText'>Style:</span> Mark your posts and replies <span class='altText'>(with a left border)</span>",
- color1Default:"#4BB2FF",
- color1Desc:"<b>Your border:</b>",
- color2Default:"#0066ff",
- color2Desc:"<b>Reply border:</b>",
- nonewline:true
- },
- markYouText:{
- default:true,
- type:"checkbox_with_colors",
- desc:"<span class='boldText'>Style:</span> Color your name and (You) links",
- color1Default:"#ff2222",
- color1Desc:"<b>Color:</b>",
- nonewline:true
- },
- compactPosts:{
- default:true,
- desc:"<span class='boldText'>Style:</span> Make thumbnails and posts more compact",
- nonewline:true
- },
- showStubs:{
- default:true,
- desc:"<span class='boldText'>Style:</span> Show post stubs when filtering",
- nonewline:true
- },
- //I swear this used to be a built in option on 8chan
- halfchanGreentexts:{
- default:false,
- desc:"<span class='boldText'>Style:</span> Make the greentext brighter like 4chan"
- },
- glowFirstPostByID:{
- default:true,
- type:"checkbox_with_colors",
- desc:"Mark new/unique posters by adding a glow effect to their ID",
- color1Default:"#26bf47",
- color1Desc:"<b>Glow color:</b>",
- nonewline:true
- },
- showPostIndex:{
- default:true,
- type:"checkbox_with_colors",
- desc:"Show the current index of a post on the thread. <span class='altText'>(OP: 1, first post: 2 etc.)</span>",
- color1Default:"#7b3bcc",
- color1Desc:"<b>Index color:</b>",
- nonewline:true
- },
- showWatcherOnLoad: {
- default:false,
- desc:'Show the "Watched Threads" popup on page load',
- nonewline:true
- },
- showVideoIcons: {
- default:true,
- desc:"Distinguish videos, gifs and audio with an icon before the filename"
- },
- preserveQuickReply:{
- default:false,
- desc:"Preserve the quick reply text when closing the box or refreshing the page",
- nonewline:true
- },
- preserveName:{
- default:false,
- desc:"Preserve the last used name between page refreshes"
- },
- reverseSearchOptions:{
- default:{
- pixiv:true,
- booru:true,
- saucenao:true
- },
- desc:"Reverse image search buttons to show:",
- type:"checkbox_multiple_dict", //Maybe "multiple_array" or "bitfield" types allowed in the future
- options:{
- pixiv:"Pixiv <span class='altText lineBefore'>Shown if the filename matches an image downloaded from Pixiv</span>",
- booru:"Gelbooru / Danbooru / Safebooru <span class='altText lineBefore'>Shown if the filename contains an md5 hash</span>",
- saucenao:"Saucenao <span class='altText lineBefore'>Always shown, uses JS to download and reupload the image to saucenao</span>"
- },
- nonewline:true
- },
- reverseSearchBooruSite:{
- desc:"Booru to link if the above option is enabled",
- type:"dropdown",
- default:"gelbooru",
- choices:{
- "gelbooru":"https://gelbooru.com/index.php?page=post&s=list&tags=md5%3a",
- "danbooru":"https://danbooru.donmai.us/posts?tags=md5%3a",
- "safebooru":"https://safebooru.org/index.php?page=post&s=list&tags=md5%3a"
- }
- }
- /*redirectToCatalog:{
- default:false,
- desc:"Redirect to catalog when clicking on the index."
- }*/
- }
- const settingsNames = Object.keys(SETTINGS_DEFINITIONS);
- //Collect all color fields for checkbox_with_colors settings
- //In the userscript storage they look like settingName_color1 etc.
- const colorSettingKeys = [];
- settingsNames.forEach(key => {
- const def = SETTINGS_DEFINITIONS[key];
- if (def.type === "checkbox_with_colors") {
- Object.keys(def).forEach(k => {
- const match = k.match(/^color(\d+)Default$/);
- if (match) {
- colorSettingKeys.push(`${key}_color${match[1]}`);
- }
- });
- }
- });
- //Compose all keys to load: main settings + color fields
- const allSettingKeys = [...settingsNames, ...colorSettingKeys];
- //For each color field, get its default from the definition
- function getDefaultForKey(key) {
- const colorMatch = key.match(/^(.+)_color(\d+)$/);
- if (colorMatch) {
- const [_, base, idx] = colorMatch;
- const def = SETTINGS_DEFINITIONS[base];
- //Return color setting default like color1Default
- return def && def[`color${idx}Default`] ? def[`color${idx}Default`] : undefined;
- }
- //Return regular setting
- return SETTINGS_DEFINITIONS[key]?.default;
- }
- const allSettingDefaults = allSettingKeys.map(getDefaultForKey);
- const allSettingValues = await Promise.all(allSettingKeys.map((key, i) => GM.getValue(key, allSettingDefaults[i])));
- const settings = Object.fromEntries(allSettingKeys.map((key, i) => [key, allSettingValues[i]]));
- function addMyStyle(newID, newStyle) {
- let myStyle = document.createElement("style");
- //myStyle.type = 'text/css';
- myStyle.id = newID;
- myStyle.textContent = newStyle;
- document.head.appendChild(myStyle);
- }
- function waitForDom(callback) {
- if (document.readyState === "loading") {
- //Loading hasn't finished yet. Wait for the inital document to load and start.
- document.addEventListener("DOMContentLoaded", callback);
- } else {
- //Document has already loaded. Start.
- callback();
- }
- }
- if (document?.head) {
- runASAP();
- } else {
- //On some environments document.head doesn't exist yet?
- waitForDom(runASAP);
- }
- async function runASAP() {
- // Migrations can be removed in a few weeks
- // Migrations are disabled now. Keeping the code for potential future migrations
- // // Migrate old useExtraStylingFixes setting if present
- // const oldStyling = await GM.getValue("useExtraStylingFixes", undefined);
- // if (typeof oldStyling !== "undefined") {
- // // If oldStyling is false, set both new options to false
- // if (oldStyling === false) {
- // settings.markPostEdge = false;
- // settings.compactPosts = false;
- // await GM.setValue("markPostEdge", false);
- // await GM.setValue("compactPosts", false);
- // }
- // // Remove the old setting
- // await GM.deleteValue("useExtraStylingFixes");
- // }
- if (settings.preserveName === false) {
- localStorage.removeItem("name");
- }
- //Secret tip for anyone manually editing colors:
- //if you edit the saved value in your userscript manager's settings database manually, you can use semi-transparent colors for the color pickers (until you click save on the settings menu).
- //or easier: just copy the relevant part of the css and paste it to the css box in the website settings. Add !important if you want to force it like: color: red !important;
- //Apply all the styles as soon as possible
- if (settings.compactPosts) {
- addMyStyle("lynx-compact-posts", `
- /* 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 {
- margin-bottom: 0.45em;
- }
- .uploadCell .imgLink {
- margin-right: 1em;
- }
- /* smaller post spacing (not too much) */
- .divMessage {
- margin: .8em .8em .5em 3em;
- }
- /* file details: reduce paddings and icon sizes */
- .uploadDetails {
- & > * {
- vertical-align: top;
- font-size: 95%;
- }
- & > .dimensionLabel {
- margin-right: 0.3ch;
- }
- .coloredIcon {
- font-size: 90%;
- }
- & > a.nameLink {
- margin-right: -2.5px;
- }
- & > span.hideFileButton {
- margin-right: -4px;
- }
- }
- /* This thing adds an unnecessary line break (only on chrome) */
- .uploadCell > details > summary + br {
- display: none;
- }
- /* Contain expanded images to the page */
- .imgExpanded {
- max-height: 100vh;
- object-fit: contain;
- }
- `);
- }
- const markerColor1 = settings.showScrollbarMarkers_color1 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color1Default;
- const markerColor2 = settings.showScrollbarMarkers_color2 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color2Default;
- const indexColor = settings.showPostIndex_color1 || SETTINGS_DEFINITIONS.showPostIndex.color1Default;
- const glowColor = settings.glowFirstPostByID_color1 || SETTINGS_DEFINITIONS.glowFirstPostByID.color1Default;
- addMyStyle("lynx-extended-css", `
- :root {
- --showScrollbarMarkers_color1: ${markerColor1};
- --showScrollbarMarkers_color2: ${markerColor2};
- --showPostIndex_color1: ${indexColor};
- --glowFirstPostByID_color1: ${glowColor};
- }
- /* Booru links */
- /* For multiple uploads the button is below the image, for single upload it's next to the filename */
- /* single upload buttons can also be moved below the image by setting the innerPost as relative. */
- .lynxReverseImageSearch a:hover {
- text-decoration: underline;
- }
- .lynxReverseImageSearch > a {
- margin: 0 4px 0 1px;
- &.fetch-awaiting:after {
- content: attr(data-booruname)" (please wait...)" !important;
- }
- &.fetch-failed:after {
- content: attr(data-booruname)" (failed!)" !important;
- }
- & > svg {
- margin-block: -2.5px;
- height: 1em;
- width: 1em;
- }
- }
- .panelUploads:not(.multipleUploads) .lynxReverseImageSearch > a:after, lynxReverseImageSearch.showSearchNames > a:after {
- content: attr(data-booruname);
- }
- .multipleUploads .uploadCell:has(.lynxReverseImageSearch) {
- position: relative;
- }
- .multipleUploads .lynxReverseImageSearch {
- position: absolute;
- bottom: 0;
- left: 0;
- z-index: 2; /* Fixes the button not being clickable if you have spoiler thumbs disabled */
- & a {
- font-size: 0.9em;
- filter: drop-shadow(0px 0px 1px black) drop-shadow(0px 0px 1px var(--contrast-color)) drop-shadow(0px 0px 1px var(--contrast-color));
- &:hover,
- &:active {
- background: var(--contrast-color);
- opacity: 0.9;
- max-width: 999px;
- &:after {
- content: attr(data-booruname);
- }
- }
- }
- }
- /* video icons for filenames */
- a.originalNameLink.lynx-video::before {
- content: "\\e0a9";
- font-family: "Icons"; /* open-iconic font from the page */
- font-size: 90%;
- margin-right: 2px;
- }
- /* Scrollbar you and reply markers */
- .marker-container {
- position: fixed;
- top: 8px;
- right: 0px;
- width: 10px;
- height: calc(100vh - 16px);
- z-index: 11000;
- pointer-events: none;
- }
- .marker {
- position: absolute;
- width: 100%;
- height: 7px;
- background: var(--showScrollbarMarkers_color1);
- cursor: pointer;
- pointer-events: auto;
- border-radius: 40% 0 0 40%;
- z-index: 5;
- filter: drop-shadow(0px 0px 1px #000000BA);
- }
- .marker.alt {
- background: var(--showScrollbarMarkers_color2);
- z-index: 2;
- }
- .postNum.index {
- color: var(--showPostIndex_color1);
- font-weight: bold;
- }
- .labelId.glows {
- box-shadow: 0 0 15px var(--glowFirstPostByID_color1);
- }
- #lynxExtendedMenu {
- position: fixed;
- top: 15px;
- left: 50%;
- transform: TranslateX(-50%);
- 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: var(--contrast-color);
- color: var(--text-color);
- border: 1px solid #737373;
- border-radius: 4px;
- max-height:90%;
- overflow-y: auto;
- line-height: 0.8em;
- & .altText {
- opacity: 0.8;
- font-size: 0.9em;
- &.lineBefore:before {
- content: "—— ";
- }
- }
- & .boldText {
- color: var(--link-color);
- font-weight: bold;
- }
- & .colorLabel {
- padding: 3px 2px 3px 4px;
- border-radius: 8px;
- box-shadow: 0px 0px 1px currentColor;
- margin-left: 0.5em;
- }
- & input[type="color"] {
- width: 40px;
- height: 20px;
- padding: 1px;
- transform: translate(0, 2px);
- }
- & button {
- padding: 10px 20px;
- margin-right: 4px;
- margin-bottom: 0;
- filter: contrast(115%) brightness(110%);
- &:hover {
- filter: brightness(130%);
- }
- }
- }
- #lynxExtendedMenu > .settings-footer {
- height: auto;
- }
- @media screen and (max-width: 1000px) {
- #lynxExtendedMenu{
- right:0;
- width:90%;
- line-height: 1em;
- /*bottom:15px;*/
- }
- }
- .lynxExtendedButton::before {
- content: "\\e0da";
- }
- `);
- if (settings.markPostEdge) {
- const color1 = settings.markPostEdge_color1 || SETTINGS_DEFINITIONS.markPostEdge.color1Default;
- const color2 = settings.markPostEdge_color2 || SETTINGS_DEFINITIONS.markPostEdge.color2Default;
- addMyStyle("lynx-mark-posts", `
- /*
- README:
- Mark your posts and replies with a left border. Specificity order: (you) > (reply).
- Important: The :not(#SP1) selectors are used for extra specificity.
- These are made extra specific so we can override ones from other userscripts.
- (because Lynx-- has an option to disable only this and also has the ability to customize the color)
- */
- /* Match your posts. This is easy. */
- body:not(#SP1#SP1) :is(.innerOP.yourPost, .innerPost.yourPost) {
- border-left: 3px dashed var(--markPostEdge_color1, ${color1});
- }
- /*
- * Match replies:
- * This can be a simple .divMessage > .quoteLink
- * or it can be a .divMessage > details > .spoiler > s > u > .quoteLink (or something like that)
- */
- body:not(#SP1) :is(.innerOP.quotesYou, .innerPost.quotesYou) {
- border-left: 2px solid var(--markPostEdge_color2, ${color2});
- }
- `);
- }
- if (settings.markYouText) {
- const color1 = settings.markYouText_color1 || SETTINGS_DEFINITIONS.markYouText.color1Default;
- addMyStyle("lynx-mark-you-text", `
- .youName { color: var(--markYouText_color1, ${color1}); }
- .you { --link-color: var(--markYouText_color1, ${color1}); }
- `);
- }
- if (settings.halfchanGreentexts) {
- addMyStyle("lynx-halfchanGreentexts",
- `.greenText {
- filter: brightness(110%);
- }
- `);
- }
- if (settings.showStubs === false) {
- addMyStyle("lynx-hide-stubs",`
- .postCell:has(> span.unhideButton.glowOnHover) {
- display: none;
- }
- `);
- }
- if (settings.revealSpoilerText=="on") {
- addMyStyle("lynx-reveal-spoilertext1",`
- span.spoiler { color: white }
- `);
- } else if (settings.revealSpoilerText=="madoka") {
- addMyStyle("lynx-reveal-spoilertext2",`
- span.spoiler:not(:hover) {
- color: white;
- font-family: MadokaRunes !important;
- }
- `);
- }
- } //End of runASAP()
- //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
- //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
- //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
- async function runAfterDom() {
- console.log("%cLynx Minus Minus Started with settings:", "color:rgb(0, 140, 255)", settings);
- //Detect if the current page is a thread
- const url = window.location.href;
- const isThread = REGEX_THREAD.test(url);
- //Keep these for now I guess.
- //Get the following window objects
- //unsafeWindow works on chrome and Tampermonkey FF, wrappedJSObject works on Firefox VM.
- //Chrome and firefox behavior is different here. In Chrome you can simply do 'api' to check if the variable is defined, in Firefox you need to do typeof api !== 'undefined'.
- //And a && will return the second element if the first is true.
- // const window_api = (typeof api !== 'undefined' && api) || window?.api || unsafeWindow?.api || wrappedJSObject?.api || undefined;
- // const window_posting = (typeof posting !== 'undefined' && posting) || window?.posting || unsafeWindow?.posting || wrappedJSObject?.posting || undefined;
- // const window_qr = (typeof qr !== 'undefined' && qr) || window?.qr || unsafeWindow?.qr || wrappedJSObject?.qr || undefined;
- // if (window_api && window_posting && window_qr) {
- // windowAccessible = true;
- // } else {
- // //I think greasemonkey sandboxes the script. I use violentmonkey though
- // console.error("Lynx Minus Minus: This JS script is sandboxed and can't access page JS... (If you can read this, let me know what browser/extension does this. Or maybe the site just failed to load?)")
- // }
- if (settings.preserveQuickReply === false) {
- const qrBody = document.getElementById("qrbody");
- if (qrBody) {
- qrBody.value = "";
- }
- }
- function pageHotkeys(ev) {
- const key = ev.key.toLowerCase();
- //Ctrl+Q or Alt+R to open quick reply
- if ((ev.ctrlKey && key == "q") || (ev.altKey && key == "r")) {
- ev.preventDefault();
- //8chan's HTML will keep the text after a reload so attempt to clear it again
- const qrBody = document.getElementById("qrbody");
- if (settings.preserveQuickReply === false) {
- if (qrBody) {
- qrBody.value = "";
- }
- }
- const replyBtn = document.getElementById("replyButton");
- replyBtn?.click();
- qrBody?.focus();
- };
- //Alt+T to toggle thread watcher
- if (ev.altKey && (key == "t")) {
- ev.preventDefault();
- const watcherBtn = document.querySelector("body > nav a.watcherButton");
- if (watcherBtn) watcherBtn.click();
- }
- }
- document.addEventListener("keydown", pageHotkeys);
- function createSettingsButton() {
- //Desktop
- document.querySelector("#navLinkSpan > .settingsButton").insertAdjacentHTML("afterend", `
- <span>/</span>
- <a id="navigation-lynxextended" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings"></a>
- `);
- //Mobile
- document.querySelector("#sidebar-menu > ul > li > .settingsButton").parentElement.insertAdjacentHTML("afterend", `
- <li>
- <a id="navigation-lynxextended-mobile" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings">Lynx Ex-- Settings</a>
- </li>
- `);
- document.querySelector("#navigation-lynxextended").addEventListener("click", openMenu);
- document.querySelector("#navigation-lynxextended-mobile").addEventListener("click", openMenu);
- }
- //Register menu command for the settings button
- 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();
- }
- //Show watched threads on page load
- if (settings.showWatcherOnLoad) {
- const watchedMenu = document.querySelector("body > #watchedMenu");
- const watcherButton = document.querySelector("body > nav > #navLinkSpan > .watcherButton");
- if (watchedMenu && watcherButton) {
- if (watchedMenu?.style?.display === "none") {
- watcherButton.click();
- }
- }
- }
- function replyKeyboardShortcuts(ev) {
- if (ev.ctrlKey) {
- let combinations = {
- "s":["[spoiler]","[/spoiler]"],
- "b":["'''","'''"],
- "u":["__","__"],
- "i":["''","''"],
- "d":["[doom]","[/doom]"],
- "m":["[moe]","[/moe]"]
- }
- for (var key in combinations)
- {
- if (ev.key == key)
- {
- ev.preventDefault();
- console.log("ctrl+"+key+" pressed in textbox")
- const textBox = ev.target;
- let newText = textBox.value;
- const tags = combinations[key]
- const selectionStart = textBox.selectionStart
- const selectionEnd = textBox.selectionEnd
- if (selectionStart == selectionEnd) { //If there is nothing selected, make empty tags and center the cursor between it
- document.execCommand("insertText",false, tags[0] + tags[1]);
- //Center the cursor between tags
- textBox.selectionStart = textBox.selectionEnd = (textBox.selectionEnd - tags[1].length);
- } else {
- //Insert text and keep undo/redo support (Only replaces highlighted text)
- document.execCommand("insertText",false, tags[0] + newText.slice(selectionStart, selectionEnd) + tags[1])
- }
- return;
- }
- }
- //Ctrl+Enter to send reply
- if (ev.key=="Enter") {
- document.getElementById("qrbutton")?.click()
- }
- }
- }
- 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 = `
- <h3 style="text-align: center; color: var(--subject-color);" class='settings-header'>LynxChan Extended-- Options</h3>
- <p style="text-align: center;"><a href="https://greasyfork.org/en/scripts/533169-lynxchan-extended-minus-minus/versions">Version ${GM.info.script.version}</a></p><br>
- `;
- //we use createElement() here instead of setting innerHTML so we can attach onclick to elements
- //...In the future, at least. There aren't any onclicks added yet.
- let settings_content = document.createElement("div");
- settings_content.classList.add("settings-content");
- Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
- const setting = SETTINGS_DEFINITIONS[name];
- if (setting.hidden) {
- //pass
- }
- else if (setting.type == "radio") {
- let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
- for (const [value, description] of Object.entries(setting.options)) {
- html += `
- <label>
- <input name="${name}" type="radio" value="${value}" ${settings[name]==value ? "checked" : ""}
- <span>${description}</span>
- </label><br>
- `;
- }
- html += `</form>${setting.nonewline ? '' : '<br>'}`;
- settings_content.innerHTML += html;
- } else if (setting.type == "checkbox_multiple_dict") {
- const dict = settings[name]
- let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
- for (const [key, description] of Object.entries(setting.options)) {
- html += `
- <label>
- <input name="${name}" type="checkbox" value="${key}" ${dict[key] ? "checked" : ""}
- <span>${description}</span>
- </label><br>
- `;
- }
- html += `</form>${setting.nonewline ? '' : '<br>'}`;
- settings_content.innerHTML += html;
- } else if (setting.type == "dropdown") {
- let html = `<label for="${name}">${setting.desc}:</label><select id="${name}">`
- Object.keys(setting['choices']).forEach(value => {
- html+=`<option value="${value}" ${settings[name]==value ? "selected" : ""}>${value}</option>`
- })
- html+=`</select><br>${setting.nonewline ? '' : '<br>'}`;
- settings_content.innerHTML += html;
- } else if (setting.type == "checkbox_with_colors") {
- let colorHtml = "";
- let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
- colorFields.forEach((colorKey) => {
- const idx = colorKey.match(/^color(\d+)Default$/)[1];
- const colorValue = settings[`${name}_color${idx}`] || setting[`color${idx}Default`];
- const colorDesc = setting[`color${idx}Desc`] || "";
- colorHtml += `
- <label class="colorLabel">
- ${colorDesc}
- <input type="color" id="${name}_color${idx}" value="${colorValue}" ${settings[name] ? '' : 'disabled'}>
- </label>
- `;
- });
- settings_content.innerHTML += `
- <label>
- <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
- ${setting.desc}
- </label>
- ${colorHtml}
- <br>${setting.nonewline ? '' : '<br>'}`;
- } else {
- settings_content.innerHTML += `
- <label>
- <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
- ${setting.desc}
- </label><br>${setting.nonewline ? '' : '<br>'}`;
- }
- })
- menu.appendChild(settings_content);
- menu.innerHTML += `
- <div class='settings-footer'>
- <button id="saveSettings">Save</button>
- <button id="closeMenu">Close</button>
- <button id="resetSettings" style="float: right;">Reset</button>
- </div>
- `;
- document.body.appendChild(menu);
- // Save button functionality
- document.getElementById("saveSettings").addEventListener("click", async () => {
- Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
- const setting = SETTINGS_DEFINITIONS[name];
- if (!('hidden' in setting)) {
- if (setting.type=="radio") {
- settings[name] = menu.querySelector(`input[name="${name}"]:checked`).value;
- } else if (setting.type == "checkbox_multiple_dict") {
- const d = {}
- menu.querySelectorAll(`input[name="${name}"]`).forEach(checkbox => {
- d[checkbox.value] = checkbox.checked;
- })
- settings[name] = d;
- } else if (setting.type=="dropdown") {
- settings[name] = document.getElementById(name).value;
- } else if (setting.type=="checkbox_with_colors") {
- settings[name] = document.getElementById(name).checked;
- let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
- colorFields.forEach((colorKey) => {
- const idx = colorKey.match(/^color(\d+)Default$/)[1];
- const colorName = `${name}_color${idx}`;
- const colorValue = document.getElementById(colorName).value;
- settings[colorName] = colorValue;
- // Set CSS variable on body (so it can be used without a refresh)
- document.body.style.setProperty(`--${colorName}`, colorValue);
- });
- } else {
- settings[name] = document.getElementById(name).checked;
- }
- }
- })
- console.log("Saving settings ",settings)
- await Promise.all(Object.entries(settings).map(([key, value]) => GM.setValue(key, value)));
- setTimeout(()=>{
- alert("Settings saved!\nFor most settings you must refresh the page for the changes to take effect.\n\n(only color pickers don't need a refresh)");
- }, 1);
- // menu.remove();
- });
- // Reset button functionality
- document.getElementById("resetSettings").addEventListener("click", async () => {
- if (!confirm("Are you sure you want to reset all settings? This will delete all saved data.")) return;
- const keys = await GM.listValues();
- await Promise.all(keys.map(key => GM.deleteValue(key)));
- alert("All settings have been reset.\nRefreshing automatically for the changes to take effect.");
- menu.remove();
- location.reload();
- });
- // Close button functionality
- document.getElementById("closeMenu").addEventListener("click", () => {
- menu.remove();
- });
- }
- function createMarker(element, container, isReply) {
- const pageHeight = document.body.scrollHeight;
- const offsetTop = element.offsetTop;
- const percent = offsetTop / pageHeight;
- const top = (percent * 100).toFixed(2);
- const marker = document.createElement("div");
- marker.classList.add("marker");
- if (isReply) {
- marker.classList.add("alt");
- }
- marker.style.top = `${top}%`;
- marker.dataset.postid = element.id;
- marker.addEventListener("click", () => {
- let elem = element?.previousElementSibling || element;
- if (elem) elem.scrollIntoView({ behavior: "instant", 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");
- 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);
- });
- }
- let postCount = 1;
- const postIndexLookup = {};
- function addPostCount(post, newpost = true) {
- // const posts = Array.from(document.querySelectorAll(".innerOP, .divPosts > .postCell"));
- if (post.querySelector(".postNum")) {
- return;
- }
- const postInfoDiv = post.getElementsByClassName("title")[0]
- if (!postInfoDiv) {
- console.error("[Lynx--] Failed to find post for div ", post);
- return;
- }
- const posterNameDiv = postInfoDiv.getElementsByClassName("linkName")[0];
- const postNumber = post.querySelector(".linkQuote")?.textContent;
- if (!postNumber) return;
- let localCount = postCount;
- if (newpost) {
- postIndexLookup[postNumber] = localCount;
- postCount++;
- } else {
- //Show cached post count for inlines & hovers
- localCount = postIndexLookup[postNumber];
- if (!localCount) return;
- }
- let newNode = document.createElement("span");
- newNode.innerText = localCount;
- newNode.className = "postNum index";
- if (localCount < Infinity) //knownBumpLimit
- {
- // color is handled by .postNum.index
- newNode.style = "";
- }
- else
- {
- newNode.style = "color: rgb(255, 4, 4); font-weight: bold;"
- }
- postInfoDiv.insertBefore(newNode, posterNameDiv);
- let foo = document.createTextNode("\u00A0"); // Non-breaking space
- postInfoDiv.insertBefore(foo, posterNameDiv);
- }
- //Open source svg from iconify.design
- const SVG_SEARCH = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" d="m20 20l-6-6"></path><path d="M15 9.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Z"></path></g></svg>`;
- function filenameFeatures(post) {
- const fileNames = post.querySelectorAll(".originalNameLink[href]");
- //Feature: Distinguish videos, gifs and audio with an icon
- //Theres also a mime type on .imgLink
- if (settings.showVideoIcons) {
- const videoExts = /\.(webm|mp4|mkv|mov|avi|flv|wmv|m4v|gif|apng|mp3|flac|opus|ogg|wav|aac|m4a|wma)$/i;
- fileNames.forEach((nameElem) => {
- if (videoExts.test(nameElem.download)) {
- nameElem.classList.add("lynx-video");
- }
- });
- }
- //Last feature: Reverse image search buttons
- //TODO MAYBE: Make this button open a small menu (like the post menu) instead of multiple buttons. AI can do it in 20 seconds.
- // .some() will return true if any value in the dict is true
- // So inverting this means no values are true, so return without doing anything
- if (!Object.values(settings.reverseSearchOptions).some(Boolean)) {
- return;
- }
- const fileNameElements = Array.from(fileNames);
- const regex_md5sum = /[0-9a-f]{32}/g;
- const regex_pixiv = /(\d+)_p\d+/;
- for (let i = fileNameElements.length-1; i>=0; i--)
- {
- const nameElem = fileNameElements[i];
- const parent = nameElem.parentElement
- if (parent.querySelector(".lynxReverseImageSearch")) {
- return;
- }
- const attachmentFileName = nameElem.download;
- const span = document.createElement("span");
- span.classList.add("lynxReverseImageSearch");
- let searchButtonsAdded = 0
- let m;
- if (settings.reverseSearchOptions.pixiv && (m = regex_pixiv.exec(attachmentFileName)) !== null) {
- span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="pixiv" href="https://pixiv.net/i/${m[1]}">${SVG_SEARCH}</a>`
- searchButtonsAdded++;
- }
- //This is 'else if' because these options are mutually exclusive - a filename will never match pixiv AND an md5 hash
- //Careful with this insane abuse of conditionals, the order of operations matters (&& is before = without parenthesis)
- //And we don't want to match >1 because that could be an 8chan hash (There should only be 1 md5 hash in a file name anyways)
- else if (settings.reverseSearchOptions.booru && (m = [...attachmentFileName.matchAll(regex_md5sum)]) && m?.length == 1) {
- span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="${settings.reverseSearchBooruSite}" href="${SETTINGS_DEFINITIONS['reverseSearchBooruSite']['choices'][settings.reverseSearchBooruSite]}${m[0]}">${SVG_SEARCH}</a>`
- searchButtonsAdded++;
- }
- if (settings.reverseSearchOptions.saucenao) {
- //Logic: for everything thats not supported i.e. not an image use the thumbnail if its available
- //Supported image extensions for Saucenao direct search
- const imageExts = /\.(png|jpe?g|webp|bmp|gif)$/i;
- let validImage = true;
- let useThumbInstead = false;
- let thumbUrl = null;
- if (!imageExts.test(attachmentFileName)) {
- // Not a supported image, try to find a thumbnail
- const uploadCell = parent.closest('.uploadCell');
- if (uploadCell) {
- const thumbImg = uploadCell.querySelector('.imgLink > img');
- const thumbSrc = thumbImg?.getAttribute("src");
- if (thumbImg && thumbSrc?.startsWith('/.media/t_')) {
- useThumbInstead = true;
- thumbUrl = thumbSrc;
- }
- }
- // If no valid thumbnail, don't add saucenao
- if (!useThumbInstead) {
- // Don't add saucenao button for this file
- validImage = false;
- }
- }
- const sauceNaoWrapper = function(ev) {
- ev.preventDefault(); //Prevent <details> node from contracting
- const a = ev.currentTarget;
- a.classList.add("fetch-awaiting");
- const fetchUrl = useThumbInstead ? thumbUrl : nameElem.href;
- const fetchName = useThumbInstead ? ("thumbnail_" + attachmentFileName) : attachmentFileName;
- fetch(fetchUrl)
- .then(resp => {
- if (!resp.ok) throw new Error("Fetch failed");
- return resp.blob();
- })
- .then(blob => {
- let file = new File([blob], fetchName, {type: blob.type} );
- let dataTransfer = new DataTransfer();
- dataTransfer.items.add(file);
- document.getElementById("saucenao_file_input").files = dataTransfer.files;
- document.getElementById("saucenao_submit").click();
- a.classList.remove("fetch-awaiting");
- })
- .catch(() => {
- a.classList.remove("fetch-awaiting");
- a.classList.add("fetch-failed");
- });
- }
- if (validImage) {
- const a = document.createElement("a");
- a.setAttribute("data-booruname","saucenao")
- // a.innerText = '🔍︎'
- a.innerHTML = SVG_SEARCH;
- a.addEventListener("click", sauceNaoWrapper)
- span.appendChild(a);
- //span.innerHTML += `<a data-booruname='saucenao' onclick=''>🔍︎</a>`
- searchButtonsAdded++;
- }
- }
- if (searchButtonsAdded > 1) {
- span.classList.add("showSearchNames")
- }
- if (searchButtonsAdded > 0) {
- parent.insertAdjacentElement("beforeend", span);
- }
- }
- }
- /*function glowpost() {
- // Create a frequency map to track occurrences of each item
- const list = document.querySelectorAll(".labelId");
- const countMap = Array.from(list).reduce((acc, item) => {
- acc[item.style.backgroundColor] = (acc[item.style.backgroundColor] || 0) + 1;
- return acc;
- }, {});
- // Filter the list to keep only items with a count of 1
- Array.from(list).filter(item => countMap[item.style.backgroundColor] === 1).forEach((item) => {
- item.style.boxShadow = "0 0 15px #26bf47";
- item.title = "This is the first post from this ID.";
- });
- }*/
- var idMap = {};
- const glowpost = function(post, newpost = true) {
- const list = post.querySelectorAll(".labelId");
- const postNumber = post.querySelector(".linkQuote")?.textContent;
- list.forEach((poster) => {
- const bgColor = poster.style.backgroundColor;
- if (newpost && idMap[bgColor] === undefined) {
- idMap[bgColor] = postNumber;
- poster.classList.add("glows");
- poster.title = "This is the first post from this ID.";
- } else if (!newpost && idMap[bgColor] == postNumber) {
- poster.classList.add("glows");
- poster.title = "This is the first post from this ID.";
- }
- });
- }
- const revealSpoilerImages = function(post) {
- const spoilers = post.querySelectorAll(".imgLink > img:is([src='/spoiler.png'],[src*='/custom.spoiler'])");
- spoilers.forEach(spoiler => {
- spoiler.classList.add('spoiler-thumb');
- const parent = spoiler.parentElement;
- const hrefTokens = parent.href.split("/");
- const fileNameTokens = hrefTokens[4].split(".");
- const thumbUrl = `/.media/t_${fileNameTokens[0]}`;
- spoiler.src = thumbUrl;
- //spoiler.style.border = "2px dotted red";
- });
- }
- if (settings.spoilerImageType.startsWith("reveal")) {
- addMyStyle("lynx-reveal-spoilerimage",`
- img.spoiler-thumb {
- transition: 0.2s;
- outline: 2px dotted #ff0000ee;
- ${settings.spoilerImageType=="reveal_blur" ? "filter: blur(10px);" : ""}
- }
- img.spoiler-thumb:hover {
- filter: blur(0);
- }
- `)
- }
- // Add functionality to apply the custom spoiler image CSS
- let threadSpoilerFound = false;
- let tsFallbackUsed = false;
- function setThreadSpoiler(post) {
- if (threadSpoilerFound) return;
- let spoilerImageUrl = null;
- //When the option is "threadAlt", fallback to "thread" if "threadAlt" doesn't exist yet.
- if (settings.spoilerImageType == "threadAlt") {
- const altSpoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoilerAlt"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
- spoilerImageUrl = altSpoilerLink ? altSpoilerLink.href : null;
- tsFallbackUsed = false; //stop looking for threadAlt
- }
- if (settings.spoilerImageType == "thread" || (!spoilerImageUrl && !tsFallbackUsed && settings.spoilerImageType == "threadAlt")) {
- const spoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoiler"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
- spoilerImageUrl = spoilerLink ? spoilerLink.href : null;
- if (settings.spoilerImageType == "threadAlt") {
- tsFallbackUsed = true; //Keep looking for threadAlt
- }
- } else if (settings.spoilerImageType == "test") {
- const myArray = [
- 'https://8chan.moe/.media/f76e9657d6b506115ccd0ade73d3d562777a441e4b6bb396610669396ff3032a.png',
- 'https://8chan.moe/.media/1074fdb6eea4ba609910581e7824106736a1bcad446ace1ae0792b231b52cf9a.png',
- 'https://8chan.moe/.media/c32b4de8490d7e77987f0e2a381d5935ffa6fec9b98c78ea7c05bd4381d6f64b.png',
- 'https://8chan.moe/.media/bb225302110d52494ec2bea68693d566acee09767212ce4ee8c0d83d49cfa05b.png'
- ];
- spoilerImageUrl = myArray[Math.floor(Math.random() * myArray.length)];
- addMyStyle("lynx-thread-spoiler-css1", `
- body {
- --spoiler-img: url("${spoilerImageUrl}")
- }
- .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]),
- .uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]) {
- background-image: var(--spoiler-img);
- background-size: cover;
- background-position: center;
- & > img {
- opacity: 0;
- }
- }
- `);
- threadSpoilerFound = true;
- return;
- }
- if (spoilerImageUrl) {
- document.head?.querySelector("#lynx-thread-spoiler-css2")?.remove(); //Remove if the style already exists (from fallback)
- addMyStyle("lynx-thread-spoiler-css2", `
- ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
- .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
- background-image: url("${spoilerImageUrl}");
- background-size: cover;
- background-position: center;
- outline: dashed 2px #ff000090;
- & > img {
- opacity: 0;
- }
- }
- `);
- if (!tsFallbackUsed) {
- threadSpoilerFound = true;
- }
- }
- }
- if (settings.spoilerImageType=="kachina") {
- addMyStyle("lynx-kachinaSpoilers",`
- ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
- .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
- background-size: cover;
- background-position: center;
- margin-right:5px;
- background-image: url("data:image/jpeg;base64,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");
- & > img {
- opacity: 0;
- }
- }
- `)
- }
- function iterateAllPosts() {
- //Get ALL posts (this does NOT include inlined posts and hovered posts)
- const allPosts = document.querySelectorAll("#divThreads > .opCell > .innerOP, .divPosts > .postCell");
- const postsArray = Array.from(allPosts); //use an array to find the last post
- postsArray.forEach((post, index) => {
- if (index == postsArray.length-1) {
- //only the last post sends batching=false
- iterateSinglePost(post, true, false);
- } else {
- iterateSinglePost(post, true, true);
- }
- });
- }
- /**
- * Processes a single post element.
- *
- * @param {HTMLElement} post - The post here can be an .innerPost or one of its containers
- * @param {boolean} newpost - True if this is a new post in the thread (i.e. not a tooltip or inline)
- * @param {boolean} batching - False if this is not from a batch from iterateAllPosts (or not the last post of the batch)
- */
- function iterateSinglePost(post, newpost = false, batching = false) {
- // console.log("Lynx-- processing post", {post}, {newpost}, {batching});
- filenameFeatures(post);
- if (settings.glowFirstPostByID)
- glowpost(post, newpost);
- if (settings.spoilerImageType.startsWith("reveal"))
- revealSpoilerImages(post);
- if (settings.showPostIndex)
- addPostCount(post, newpost);
- //Run only if its a new post in the thread
- if (newpost) {
- if (settings.spoilerImageType=="thread" || settings.spoilerImageType=="threadAlt")
- setThreadSpoiler(post);
- //This still has to iterate all posts, do it last and only when necessary.
- //These are manually ran in the mutation observer
- if (batching === false && settings.showScrollbarMarkers)
- recreateScrollMarkers();
- }
- }
- //ANYTHING BELOW ONLY RUNS ON THREAD PAGES (if (isThread))
- //ANYTHING BELOW ONLY RUNS ON THREAD PAGES (if (isThread))
- //99% of above are functions. They can be ignored.
- if (isThread) {
- if (settings.addKeyboardHandlers) {
- document.getElementById("qrbody")?.addEventListener("keydown", replyKeyboardShortcuts);
- document.getElementById("quick-reply")?.addEventListener('keydown',function(ev) {
- if (ev.key == "Escape") {
- document.getElementById("quick-reply")?.querySelector(".close-btn").click();
- }
- })
- }
- //I'm not sure who would ever want this on but I'm making it an option anyways
- if (settings.preserveQuickReply===false) {
- document.getElementById("quick-reply")?.querySelector(".close-btn")?.addEventListener("click", function(ev){
- document.getElementById("qrbody").value = "";
- });
- //This doesn't replace the built in onclick but adds to it so the original onclick will still bring up the qr
- document.getElementById("replyButton")?.addEventListener("click", function(ev){
- ev.preventDefault();
- const qrBody = document.getElementById("qrbody");
- if (qrBody) {
- qrBody.value = "";
- qrBody?.focus();
- }
- });
- }
- if (settings.reverseSearchOptions.saucenao) {
- //have to shove this at the bottom of the document since the entire thread is inside a form div and I can't nest it
- const formm =`
- <form target="_blank" action="https://saucenao.com/search.php" method="POST" enctype="multipart/form-data" style="display:none">
- <input type="file" name="file" size="50" id='saucenao_file_input'>
- <input type="submit" accesskey="s" value="get sauce" id='saucenao_submit'>
- </form>`
- document.body.insertAdjacentHTML('beforeend', formm);
- }
- //Start running and observing, no need for a delay.
- iterateAllPosts();
- //Observe posts and all their children
- const observer = new MutationObserver((mt_callback) => {
- let foundNewPost = false;
- mt_callback.forEach(mut => {
- if (mut.type == "childList" && mut.addedNodes?.length > 0) {
- //console.log("MutationObserver!!!");
- mut.addedNodes.forEach(node => {
- //New posts, new inlined posts, new hovered posts all contain .innerPost and are always in a div container.
- //New posts are div.postCell and new inlines are div.inlineQuote
- if (node.tagName === "DIV") {
- // console.log("lynx ~ observer:", {node}, {mut});
- //batching=true for both new posts and inlined posts
- if (node.classList.contains("postCell")) {
- foundNewPost = true;
- iterateSinglePost(node, true, true);
- } else if (node.classList.contains("inlineQuote")) {
- iterateSinglePost(node, false, true);
- }
- }
- });
- }
- });
- //Manually run all batching=false actions here
- if (foundNewPost && settings.showScrollbarMarkers) {
- recreateScrollMarkers();
- }
- });
- observer.observe(document.querySelector(".divPosts"), {childList: true, subtree: true});
- //Observe the hover tooltip (ignore everything else)
- const toolObserver = new MutationObserver((mutationsList) => {
- for (const mutation of mutationsList) {
- if (mutation.type === 'childList') {
- mutation.addedNodes.forEach(node => {
- if (node.tagName === "DIV" && node.matches(".innerPost, .innerOP")) {
- //New hover tooltip found
- iterateSinglePost(node, false, false);
- }
- });
- }
- }
- });
- const quoteTooltip = document.body?.querySelector(":scope > div.quoteTooltip");
- if (quoteTooltip) {
- toolObserver.observe(quoteTooltip, {childList: true});
- }
- }
- } //End of runAfterDom()
- //Starting runAfterDom when the document is ready
- waitForDom(runAfterDom);
- })();