LynxChan Extended Minus Minus

LynxChan Extended with even more features

安装此脚本
作者推荐脚本

您可能也喜欢8chan-buffs

安装此脚本
  1. // ==UserScript==
  2. // @name LynxChan Extended Minus Minus
  3. // @namespace https://rentry.org/8chanMinusMinus
  4. // @version 2.3.18
  5. // @description LynxChan Extended with even more features
  6. // @author SaddestPanda & Dandelion & /gfg/
  7. // @license UNLICENSE
  8. // @match *://8chan.moe/*
  9. // @match *://8chan.se/*
  10. // @match *://8chan.cc/*
  11. // @match *://alephchvkipd2houttjirmgivro5pxullvcgm4c47ptm7mhubbja6kad.onion/*
  12. // @grant GM.getValue
  13. // @grant GM.setValue
  14. // @grant GM.deleteValue
  15. // @grant GM.registerMenuCommand
  16. // @run-at document-start
  17. // ==/UserScript==
  18.  
  19. //TODO LATER MAYBE: combine all CSS into one <style> and use classes on html or body instead.
  20. (async function () {
  21. "use strict";
  22.  
  23. const REGEX_THREAD = /\/res|last\//;
  24. const SETTINGS_DEFINITIONS = {
  25. firstRun:{
  26. default:true,
  27. hidden:true,
  28. desc:"You shouldn't be able to see this setting! (firstRun)"
  29. },
  30. addKeyboardHandlers:{
  31. default:true,
  32. desc:"Add keyboard Ctrl+ hotkeys to the quick reply box (Disable this for 8chanSS compatibility)"
  33. },
  34. showScrollbarMarkers:{
  35. default:true,
  36. type:"checkbox_with_colors",
  37. desc:"Show your posts and replies on the scrollbar",
  38. color1Default:"#0092ff",
  39. color1Desc:"<b>Your marker:</b>",
  40. color2Default:"#a8d8f8",
  41. color2Desc:"<b>Reply marker:</b>"
  42. },
  43. spoilerImageType:{
  44. default:"off",
  45. desc:"Override how the spoiler thumbnail looks:",
  46. type:"radio",
  47. options:{
  48. off:"Don't change the thumbnail.",
  49. reveal:"Reveal spoilers <span class='altText lineBefore'>(Previously spoilered images will have a red border around them indicating that they're spoilers.)</span>",
  50. reveal_blur:"Change to a blurred thumbnail <span class='altText lineBefore'>(Unblurred when you hover your mouse over.)</span>",
  51. kachina:"Makes the spoiler image Kachina from Genshin Impact.",
  52. 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>`,
  53. 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>`,
  54. //test:`[TEST OPTION] Set custom spoiler thumb per-thread (For /gacha/ only!)`
  55. },
  56. nonewline:true
  57. },
  58. overrideBoardSpoilerImage: {
  59. default:true,
  60. parent:"spoilerImageType",
  61. //Not implemented yet
  62. //depends: function() {return settings.spoilerImageType != "off"},
  63. 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>"
  64. },
  65. revealSpoilerText:{
  66. default:"off",
  67. desc:"Reveal the spoiler text. Or make it into madoka runes.",
  68. type:"radio",
  69. options:{
  70. off:"Don't reveal spoilers.",
  71. on:"Spoilers will be always be shown by turning the text white.",
  72. 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.`
  73. }
  74. },
  75. markPostEdge:{
  76. default:true,
  77. type:"checkbox_with_colors",
  78. desc:"<span class='boldText'>Style:</span> Mark your posts and replies <span class='altText'>(with a left border)</span>",
  79. color1Default:"#4BB2FF",
  80. color1Desc:"<b>Your border:</b>",
  81. color2Default:"#0066ff",
  82. color2Desc:"<b>Reply border:</b>",
  83. nonewline:true
  84. },
  85. markYouText:{
  86. default:true,
  87. type:"checkbox_with_colors",
  88. desc:"<span class='boldText'>Style:</span> Color your name and (You) links",
  89. color1Default:"#ff2222",
  90. color1Desc:"<b>Color:</b>",
  91. nonewline:true
  92. },
  93. compactPosts:{
  94. default:true,
  95. desc:"<span class='boldText'>Style:</span> Make thumbnails and posts more compact",
  96. nonewline:true
  97. },
  98. showStubs:{
  99. default:true,
  100. desc:"<span class='boldText'>Style:</span> Show post stubs when filtering",
  101. nonewline:true
  102. },
  103. //I swear this used to be a built in option on 8chan
  104. halfchanGreentexts:{
  105. default:false,
  106. desc:"<span class='boldText'>Style:</span> Make the greentext brighter like 4chan"
  107. },
  108. glowFirstPostByID:{
  109. default:true,
  110. type:"checkbox_with_colors",
  111. desc:"Mark new/unique posters by adding a glow effect to their ID",
  112. color1Default:"#26bf47",
  113. color1Desc:"<b>Glow color:</b>",
  114. nonewline:true
  115. },
  116. showPostIndex:{
  117. default:true,
  118. type:"checkbox_with_colors",
  119. desc:"Show the current index of a post on the thread. <span class='altText'>(OP: 1, first post: 2 etc.)</span>",
  120. color1Default:"#7b3bcc",
  121. color1Desc:"<b>Index color:</b>",
  122. nonewline:true
  123. },
  124. showWatcherOnLoad: {
  125. default:false,
  126. desc:'Show the "Watched Threads" popup on page load',
  127. nonewline:true
  128. },
  129. showVideoIcons: {
  130. default:true,
  131. desc:"Distinguish videos, gifs and audio with an icon before the filename"
  132. },
  133. preserveQuickReply:{
  134. default:false,
  135. desc:"Preserve the quick reply text when closing the box or refreshing the page",
  136. nonewline:true
  137. },
  138. preserveName:{
  139. default:false,
  140. desc:"Preserve the last used name between page refreshes"
  141. },
  142. reverseSearchOptions:{
  143. default:{
  144. pixiv:true,
  145. booru:true,
  146. saucenao:true
  147. },
  148. desc:"Reverse image search buttons to show:",
  149. type:"checkbox_multiple_dict", //Maybe "multiple_array" or "bitfield" types allowed in the future
  150. options:{
  151. pixiv:"Pixiv <span class='altText lineBefore'>Shown if the filename matches an image downloaded from Pixiv</span>",
  152. booru:"Gelbooru / Danbooru / Safebooru <span class='altText lineBefore'>Shown if the filename contains an md5 hash</span>",
  153. saucenao:"Saucenao <span class='altText lineBefore'>Always shown, uses JS to download and reupload the image to saucenao</span>"
  154. },
  155. nonewline:true
  156. },
  157. reverseSearchBooruSite:{
  158. desc:"Booru to link if the above option is enabled",
  159. type:"dropdown",
  160. default:"gelbooru",
  161. choices:{
  162. "gelbooru":"https://gelbooru.com/index.php?page=post&s=list&tags=md5%3a",
  163. "danbooru":"https://danbooru.donmai.us/posts?tags=md5%3a",
  164. "safebooru":"https://safebooru.org/index.php?page=post&s=list&tags=md5%3a"
  165. }
  166. }
  167. /*redirectToCatalog:{
  168. default:false,
  169. desc:"Redirect to catalog when clicking on the index."
  170. }*/
  171. }
  172.  
  173. const settingsNames = Object.keys(SETTINGS_DEFINITIONS);
  174.  
  175. //Collect all color fields for checkbox_with_colors settings
  176. //In the userscript storage they look like settingName_color1 etc.
  177. const colorSettingKeys = [];
  178. settingsNames.forEach(key => {
  179. const def = SETTINGS_DEFINITIONS[key];
  180. if (def.type === "checkbox_with_colors") {
  181. Object.keys(def).forEach(k => {
  182. const match = k.match(/^color(\d+)Default$/);
  183. if (match) {
  184. colorSettingKeys.push(`${key}_color${match[1]}`);
  185. }
  186. });
  187. }
  188. });
  189.  
  190. //Compose all keys to load: main settings + color fields
  191. const allSettingKeys = [...settingsNames, ...colorSettingKeys];
  192.  
  193. //For each color field, get its default from the definition
  194. function getDefaultForKey(key) {
  195. const colorMatch = key.match(/^(.+)_color(\d+)$/);
  196. if (colorMatch) {
  197. const [_, base, idx] = colorMatch;
  198. const def = SETTINGS_DEFINITIONS[base];
  199. //Return color setting default like color1Default
  200. return def && def[`color${idx}Default`] ? def[`color${idx}Default`] : undefined;
  201. }
  202. //Return regular setting
  203. return SETTINGS_DEFINITIONS[key]?.default;
  204. }
  205.  
  206. const allSettingDefaults = allSettingKeys.map(getDefaultForKey);
  207. const allSettingValues = await Promise.all(allSettingKeys.map((key, i) => GM.getValue(key, allSettingDefaults[i])));
  208. const settings = Object.fromEntries(allSettingKeys.map((key, i) => [key, allSettingValues[i]]));
  209.  
  210. function addMyStyle(newID, newStyle) {
  211. let myStyle = document.createElement("style");
  212. //myStyle.type = 'text/css';
  213. myStyle.id = newID;
  214. myStyle.textContent = newStyle;
  215. document.head.appendChild(myStyle);
  216. }
  217.  
  218. function waitForDom(callback) {
  219. if (document.readyState === "loading") {
  220. //Loading hasn't finished yet. Wait for the inital document to load and start.
  221. document.addEventListener("DOMContentLoaded", callback);
  222. } else {
  223. //Document has already loaded. Start.
  224. callback();
  225. }
  226. }
  227.  
  228. if (document?.head) {
  229. runASAP();
  230. } else {
  231. //On some environments document.head doesn't exist yet?
  232. waitForDom(runASAP);
  233. }
  234.  
  235. async function runASAP() {
  236. // Migrations can be removed in a few weeks
  237. // Migrations are disabled now. Keeping the code for potential future migrations
  238.  
  239. // // Migrate old useExtraStylingFixes setting if present
  240. // const oldStyling = await GM.getValue("useExtraStylingFixes", undefined);
  241. // if (typeof oldStyling !== "undefined") {
  242. // // If oldStyling is false, set both new options to false
  243. // if (oldStyling === false) {
  244. // settings.markPostEdge = false;
  245. // settings.compactPosts = false;
  246. // await GM.setValue("markPostEdge", false);
  247. // await GM.setValue("compactPosts", false);
  248. // }
  249. // // Remove the old setting
  250. // await GM.deleteValue("useExtraStylingFixes");
  251. // }
  252.  
  253. if (settings.preserveName === false) {
  254. localStorage.removeItem("name");
  255. }
  256.  
  257. //Secret tip for anyone manually editing colors:
  258. //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).
  259. //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;
  260.  
  261. //Apply all the styles as soon as possible
  262. if (settings.compactPosts) {
  263. addMyStyle("lynx-compact-posts", `
  264. /* smaller thumbnails & image paddings */
  265. body .uploadCell img:not(.imgExpanded) {
  266. max-width: 160px;
  267. max-height: 125px;
  268. object-fit: contain;
  269. height: auto;
  270. width: auto;
  271. margin-right: 0em;
  272. margin-bottom: 0em;
  273. }
  274.  
  275. .uploadCell {
  276. margin-bottom: 0.45em;
  277. }
  278.  
  279. .uploadCell .imgLink {
  280. margin-right: 1em;
  281. }
  282.  
  283. /* smaller post spacing (not too much) */
  284. .divMessage {
  285. margin: .8em .8em .5em 3em;
  286. }
  287.  
  288. /* file details: reduce paddings and icon sizes */
  289. .uploadDetails {
  290. & > * {
  291. vertical-align: top;
  292. font-size: 95%;
  293. }
  294. & > .dimensionLabel {
  295. margin-right: 0.3ch;
  296. }
  297. .coloredIcon {
  298. font-size: 90%;
  299. }
  300. & > a.nameLink {
  301. margin-right: -2.5px;
  302. }
  303. & > span.hideFileButton {
  304. margin-right: -4px;
  305. }
  306. }
  307.  
  308. /* This thing adds an unnecessary line break (only on chrome) */
  309. .uploadCell > details > summary + br {
  310. display: none;
  311. }
  312.  
  313. /* Contain expanded images to the page */
  314. .imgExpanded {
  315. max-height: 100vh;
  316. object-fit: contain;
  317. }
  318.  
  319. `);
  320. }
  321.  
  322. const markerColor1 = settings.showScrollbarMarkers_color1 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color1Default;
  323. const markerColor2 = settings.showScrollbarMarkers_color2 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color2Default;
  324. const indexColor = settings.showPostIndex_color1 || SETTINGS_DEFINITIONS.showPostIndex.color1Default;
  325. const glowColor = settings.glowFirstPostByID_color1 || SETTINGS_DEFINITIONS.glowFirstPostByID.color1Default;
  326. addMyStyle("lynx-extended-css", `
  327. :root {
  328. --showScrollbarMarkers_color1: ${markerColor1};
  329. --showScrollbarMarkers_color2: ${markerColor2};
  330. --showPostIndex_color1: ${indexColor};
  331. --glowFirstPostByID_color1: ${glowColor};
  332. }
  333.  
  334. /* Booru links */
  335. /* For multiple uploads the button is below the image, for single upload it's next to the filename */
  336. /* single upload buttons can also be moved below the image by setting the innerPost as relative. */
  337. .lynxReverseImageSearch a:hover {
  338. text-decoration: underline;
  339. }
  340. .lynxReverseImageSearch > a {
  341. margin: 0 4px 0 1px;
  342.  
  343. &.fetch-awaiting:after {
  344. content: attr(data-booruname)" (please wait...)" !important;
  345. }
  346.  
  347. &.fetch-failed:after {
  348. content: attr(data-booruname)" (failed!)" !important;
  349. }
  350.  
  351. & > svg {
  352. margin-block: -2.5px;
  353. height: 1em;
  354. width: 1em;
  355. }
  356. }
  357.  
  358. .panelUploads:not(.multipleUploads) .lynxReverseImageSearch > a:after, lynxReverseImageSearch.showSearchNames > a:after {
  359. content: attr(data-booruname);
  360. }
  361.  
  362. .multipleUploads .uploadCell:has(.lynxReverseImageSearch) {
  363. position: relative;
  364. }
  365.  
  366. .multipleUploads .lynxReverseImageSearch {
  367. position: absolute;
  368. bottom: 0;
  369. left: 0;
  370. z-index: 2; /* Fixes the button not being clickable if you have spoiler thumbs disabled */
  371.  
  372. & a {
  373. font-size: 0.9em;
  374. filter: drop-shadow(0px 0px 1px black) drop-shadow(0px 0px 1px var(--contrast-color)) drop-shadow(0px 0px 1px var(--contrast-color));
  375. &:hover,
  376. &:active {
  377. background: var(--contrast-color);
  378. opacity: 0.9;
  379. max-width: 999px;
  380. &:after {
  381. content: attr(data-booruname);
  382. }
  383. }
  384. }
  385. }
  386.  
  387. /* video icons for filenames */
  388. a.originalNameLink.lynx-video::before {
  389. content: "\\e0a9";
  390. font-family: "Icons"; /* open-iconic font from the page */
  391. font-size: 90%;
  392. margin-right: 2px;
  393. }
  394.  
  395. /* Scrollbar you and reply markers */
  396. .marker-container {
  397. position: fixed;
  398. top: 8px;
  399. right: 0px;
  400. width: 10px;
  401. height: calc(100vh - 16px);
  402. z-index: 11000;
  403. pointer-events: none;
  404. }
  405.  
  406. .marker {
  407. position: absolute;
  408. width: 100%;
  409. height: 7px;
  410. background: var(--showScrollbarMarkers_color1);
  411. cursor: pointer;
  412. pointer-events: auto;
  413. border-radius: 40% 0 0 40%;
  414. z-index: 5;
  415. filter: drop-shadow(0px 0px 1px #000000BA);
  416. }
  417.  
  418. .marker.alt {
  419. background: var(--showScrollbarMarkers_color2);
  420. z-index: 2;
  421. }
  422.  
  423. .postNum.index {
  424. color: var(--showPostIndex_color1);
  425. font-weight: bold;
  426. }
  427.  
  428. .labelId.glows {
  429. box-shadow: 0 0 15px var(--glowFirstPostByID_color1);
  430. }
  431.  
  432. #lynxExtendedMenu {
  433. position: fixed;
  434. top: 15px;
  435. left: 50%;
  436. transform: TranslateX(-50%);
  437. padding: 10px;
  438. z-index: 10000;
  439. font-family: Arial, sans-serif;
  440. font-size: 14px;
  441. box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
  442. background: var(--contrast-color);
  443. color: var(--text-color);
  444. border: 1px solid #737373;
  445. border-radius: 4px;
  446. max-height:90%;
  447. overflow-y: auto;
  448. line-height: 0.8em;
  449.  
  450. & .altText {
  451. opacity: 0.8;
  452. font-size: 0.9em;
  453.  
  454. &.lineBefore:before {
  455. content: "—— ";
  456. }
  457. }
  458.  
  459. & .boldText {
  460. color: var(--link-color);
  461. font-weight: bold;
  462. }
  463.  
  464. & .colorLabel {
  465. padding: 3px 2px 3px 4px;
  466. border-radius: 8px;
  467. box-shadow: 0px 0px 1px currentColor;
  468. margin-left: 0.5em;
  469. }
  470.  
  471. & input[type="color"] {
  472. width: 40px;
  473. height: 20px;
  474. padding: 1px;
  475. transform: translate(0, 2px);
  476. }
  477.  
  478. & button {
  479. padding: 10px 20px;
  480. margin-right: 4px;
  481. margin-bottom: 0;
  482. filter: contrast(115%) brightness(110%);
  483. &:hover {
  484. filter: brightness(130%);
  485. }
  486. }
  487. }
  488. #lynxExtendedMenu > .settings-footer {
  489. height: auto;
  490. }
  491. @media screen and (max-width: 1000px) {
  492. #lynxExtendedMenu{
  493. right:0;
  494. width:90%;
  495. line-height: 1em;
  496. /*bottom:15px;*/
  497. }
  498. }
  499.  
  500. .lynxExtendedButton::before {
  501. content: "\\e0da";
  502. }
  503.  
  504. `);
  505.  
  506. if (settings.markPostEdge) {
  507. const color1 = settings.markPostEdge_color1 || SETTINGS_DEFINITIONS.markPostEdge.color1Default;
  508. const color2 = settings.markPostEdge_color2 || SETTINGS_DEFINITIONS.markPostEdge.color2Default;
  509. addMyStyle("lynx-mark-posts", `
  510. /*
  511. README:
  512. Mark your posts and replies with a left border. Specificity order: (you) > (reply).
  513. Important: The :not(#SP1) selectors and the !important are used for extra specificity.
  514. These are made extra specific so we can override ones from other userscripts.
  515. (because Lynx-- has an option to disable only this and also has the ability to customize the color)
  516. */
  517. /* Match your posts. This is easy. */
  518. body:not(#SP1#SP1) .yourPost {
  519. border-left: 3px dashed var(--markPostEdge_color1, ${color1}) !important;
  520. }
  521.  
  522. /*
  523. * Match replies:
  524. * This can be a simple .divMessage > .quoteLink
  525. * or it can be a .divMessage > details > .spoiler > s > u > .quoteLink (or something like that)
  526. */
  527. body:not(#SP1) .quotesYou {
  528. border-left: 2px solid var(--markPostEdge_color2, ${color2}) !important;
  529. }
  530. `);
  531. }
  532.  
  533. if (settings.markYouText) {
  534. const color1 = settings.markYouText_color1 || SETTINGS_DEFINITIONS.markYouText.color1Default;
  535. addMyStyle("lynx-mark-you-text", `
  536. .youName { color: var(--markYouText_color1, ${color1}); }
  537. .you { --link-color: var(--markYouText_color1, ${color1}); }
  538. `);
  539. }
  540.  
  541. if (settings.halfchanGreentexts) {
  542. addMyStyle("lynx-halfchanGreentexts",
  543. `.greenText {
  544. filter: brightness(110%);
  545. }
  546. `);
  547. }
  548.  
  549. if (settings.showStubs === false) {
  550. addMyStyle("lynx-hide-stubs",`
  551. .postCell:has(> span.unhideButton.glowOnHover) {
  552. display: none;
  553. }
  554. `);
  555. }
  556.  
  557. if (settings.revealSpoilerText=="on") {
  558. addMyStyle("lynx-reveal-spoilertext1",`
  559. span.spoiler { color: white }
  560. `);
  561. } else if (settings.revealSpoilerText=="madoka") {
  562. addMyStyle("lynx-reveal-spoilertext2",`
  563. span.spoiler:not(:hover) {
  564. color: white;
  565. font-family: MadokaRunes !important;
  566. }
  567. `);
  568. }
  569.  
  570. } //End of runASAP()
  571.  
  572. //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
  573. //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
  574. //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
  575. async function runAfterDom() {
  576. console.log("%cLynx Minus Minus Started with settings:", "color:rgb(0, 140, 255)", settings);
  577.  
  578. //Detect if the current page is a thread
  579. const url = window.location.href;
  580. const isThread = REGEX_THREAD.test(url);
  581.  
  582. //Keep these for now I guess.
  583. //Get the following window objects
  584. //unsafeWindow works on chrome and Tampermonkey FF, wrappedJSObject works on Firefox VM.
  585. //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'.
  586. //And a && will return the second element if the first is true.
  587.  
  588. // const window_api = (typeof api !== 'undefined' && api) || window?.api || unsafeWindow?.api || wrappedJSObject?.api || undefined;
  589. // const window_posting = (typeof posting !== 'undefined' && posting) || window?.posting || unsafeWindow?.posting || wrappedJSObject?.posting || undefined;
  590. // const window_qr = (typeof qr !== 'undefined' && qr) || window?.qr || unsafeWindow?.qr || wrappedJSObject?.qr || undefined;
  591. // if (window_api && window_posting && window_qr) {
  592. // windowAccessible = true;
  593. // } else {
  594. // //I think greasemonkey sandboxes the script. I use violentmonkey though
  595. // 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?)")
  596. // }
  597.  
  598. if (settings.preserveQuickReply === false) {
  599. const qrBody = document.getElementById("qrbody");
  600. if (qrBody) {
  601. qrBody.value = "";
  602. }
  603. }
  604.  
  605. function pageHotkeys(ev) {
  606. const key = ev.key.toLowerCase();
  607. //Ctrl+Q or Alt+R to open quick reply
  608. if ((ev.ctrlKey && key == "q") || (ev.altKey && key == "r")) {
  609. ev.preventDefault();
  610. //8chan's HTML will keep the text after a reload so attempt to clear it again
  611. const qrBody = document.getElementById("qrbody");
  612. if (settings.preserveQuickReply === false) {
  613. if (qrBody) {
  614. qrBody.value = "";
  615. }
  616. }
  617. const replyBtn = document.getElementById("replyButton");
  618. replyBtn?.click();
  619. qrBody?.focus();
  620. };
  621. //Alt+T to toggle thread watcher
  622. if (ev.altKey && (key == "t")) {
  623. ev.preventDefault();
  624. const watcherBtn = document.querySelector("body > nav a.watcherButton");
  625. if (watcherBtn) watcherBtn.click();
  626. }
  627. }
  628. document.addEventListener("keydown", pageHotkeys);
  629.  
  630. function createSettingsButton() {
  631. //Desktop
  632. document.querySelector("#navLinkSpan > .settingsButton").insertAdjacentHTML("afterend", `
  633. <span>/</span>
  634. <a id="navigation-lynxextended" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings"></a>
  635. `);
  636. //Mobile
  637. document.querySelector("#sidebar-menu > ul > li > .settingsButton").parentElement.insertAdjacentHTML("afterend", `
  638. <li>
  639. <a id="navigation-lynxextended-mobile" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings">Lynx Ex-- Settings</a>
  640. </li>
  641. `);
  642. document.querySelector("#navigation-lynxextended").addEventListener("click", openMenu);
  643. document.querySelector("#navigation-lynxextended-mobile").addEventListener("click", openMenu);
  644. }
  645.  
  646. //Register menu command for the settings button
  647. GM.registerMenuCommand("Show Options Menu", openMenu);
  648. try {
  649. createSettingsButton();
  650. } catch (error) {
  651. //Don't log errors on the disclaimer page (constantly seen by private browsing users)
  652. if (document.location.href.includes("disclaimer.html") === false) {
  653. console.log("Error while creating settings button:", error);
  654. }
  655. // return; //If the button creation fails, don't continue (not sure if this is needed)
  656. }
  657.  
  658. //Open the settings menu on the first run
  659. if (settings.firstRun) {
  660. settings.firstRun = false;
  661. await GM.setValue("firstRun", settings.firstRun);
  662. openMenu();
  663. }
  664.  
  665. //Show watched threads on page load
  666. if (settings.showWatcherOnLoad) {
  667. const watchedMenu = document.querySelector("body > #watchedMenu");
  668. const watcherButton = document.querySelector("body > nav > #navLinkSpan > .watcherButton");
  669. if (watchedMenu && watcherButton) {
  670. if (watchedMenu?.style?.display === "none") {
  671. watcherButton.click();
  672. }
  673. }
  674. }
  675.  
  676. function replyKeyboardShortcuts(ev) {
  677. if (ev.ctrlKey) {
  678. let combinations = {
  679. "s":["[spoiler]","[/spoiler]"],
  680. "b":["'''","'''"],
  681. "u":["__","__"],
  682. "i":["''","''"],
  683. "d":["[doom]","[/doom]"],
  684. "m":["[moe]","[/moe]"]
  685. }
  686. for (var key in combinations)
  687. {
  688. if (ev.key == key)
  689. {
  690. ev.preventDefault();
  691. console.log("ctrl+"+key+" pressed in textbox")
  692. const textBox = ev.target;
  693. let newText = textBox.value;
  694. const tags = combinations[key]
  695. const selectionStart = textBox.selectionStart
  696. const selectionEnd = textBox.selectionEnd
  697. if (selectionStart == selectionEnd) { //If there is nothing selected, make empty tags and center the cursor between it
  698. document.execCommand("insertText",false, tags[0] + tags[1]);
  699. //Center the cursor between tags
  700. textBox.selectionStart = textBox.selectionEnd = (textBox.selectionEnd - tags[1].length);
  701. } else {
  702. //Insert text and keep undo/redo support (Only replaces highlighted text)
  703. document.execCommand("insertText",false, tags[0] + newText.slice(selectionStart, selectionEnd) + tags[1])
  704. }
  705. return;
  706. }
  707. }
  708. //Ctrl+Enter to send reply
  709. if (ev.key=="Enter") {
  710. document.getElementById("qrbutton")?.click()
  711. }
  712. }
  713. }
  714.  
  715.  
  716. function openMenu() {
  717. const oldMenu = document.getElementById("lynxExtendedMenu");
  718. if (oldMenu) {
  719. oldMenu.remove();
  720. return;
  721. }
  722. // Create options menu
  723. const menu = document.createElement("div");
  724. menu.id = "lynxExtendedMenu";
  725. menu.innerHTML = `
  726. <h3 style="text-align: center; color: var(--subject-color);" class='settings-header'>LynxChan Extended-- Options</h3>
  727. <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>
  728. `;
  729.  
  730. //we use createElement() here instead of setting innerHTML so we can attach onclick to elements
  731. //...In the future, at least. There aren't any onclicks added yet.
  732. let settings_content = document.createElement("div");
  733. settings_content.classList.add("settings-content");
  734. Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
  735. const setting = SETTINGS_DEFINITIONS[name];
  736. if (setting.hidden) {
  737. //pass
  738. }
  739. else if (setting.type == "radio") {
  740. let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
  741. for (const [value, description] of Object.entries(setting.options)) {
  742. html += `
  743. <label>
  744. <input name="${name}" type="radio" value="${value}" ${settings[name]==value ? "checked" : ""}
  745. <span>${description}</span>
  746. </label><br>
  747. `;
  748. }
  749. html += `</form>${setting.nonewline ? '' : '<br>'}`;
  750. settings_content.innerHTML += html;
  751. } else if (setting.type == "checkbox_multiple_dict") {
  752. const dict = settings[name]
  753. let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
  754. for (const [key, description] of Object.entries(setting.options)) {
  755. html += `
  756. <label>
  757. <input name="${name}" type="checkbox" value="${key}" ${dict[key] ? "checked" : ""}
  758. <span>${description}</span>
  759. </label><br>
  760. `;
  761. }
  762. html += `</form>${setting.nonewline ? '' : '<br>'}`;
  763. settings_content.innerHTML += html;
  764. } else if (setting.type == "dropdown") {
  765. let html = `<label for="${name}">${setting.desc}:</label><select id="${name}">`
  766. Object.keys(setting['choices']).forEach(value => {
  767. html+=`<option value="${value}" ${settings[name]==value ? "selected" : ""}>${value}</option>`
  768. })
  769. html+=`</select><br>${setting.nonewline ? '' : '<br>'}`;
  770. settings_content.innerHTML += html;
  771.  
  772. } else if (setting.type == "checkbox_with_colors") {
  773. let colorHtml = "";
  774. let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
  775. colorFields.forEach((colorKey) => {
  776. const idx = colorKey.match(/^color(\d+)Default$/)[1];
  777. const colorValue = settings[`${name}_color${idx}`] || setting[`color${idx}Default`];
  778. const colorDesc = setting[`color${idx}Desc`] || "";
  779. colorHtml += `
  780. <label class="colorLabel">
  781. ${colorDesc}
  782. <input type="color" id="${name}_color${idx}" value="${colorValue}" ${settings[name] ? '' : 'disabled'}>
  783. </label>
  784. `;
  785. });
  786. settings_content.innerHTML += `
  787. <label>
  788. <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
  789. ${setting.desc}
  790. </label>
  791. ${colorHtml}
  792. <br>${setting.nonewline ? '' : '<br>'}`;
  793. } else {
  794. settings_content.innerHTML += `
  795. <label>
  796. <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
  797. ${setting.desc}
  798. </label><br>${setting.nonewline ? '' : '<br>'}`;
  799. }
  800. })
  801. menu.appendChild(settings_content);
  802. menu.innerHTML += `
  803. <div class='settings-footer'>
  804. <button id="saveSettings">Save</button>
  805. <button id="closeMenu">Close</button>
  806. <button id="resetSettings" style="float: right;">Reset</button>
  807. </div>
  808. `;
  809. document.body.appendChild(menu);
  810.  
  811. // Save button functionality
  812. document.getElementById("saveSettings").addEventListener("click", async () => {
  813. Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
  814. const setting = SETTINGS_DEFINITIONS[name];
  815. if (!('hidden' in setting)) {
  816. if (setting.type=="radio") {
  817. settings[name] = menu.querySelector(`input[name="${name}"]:checked`).value;
  818. } else if (setting.type == "checkbox_multiple_dict") {
  819. const d = {}
  820. menu.querySelectorAll(`input[name="${name}"]`).forEach(checkbox => {
  821. d[checkbox.value] = checkbox.checked;
  822. })
  823. settings[name] = d;
  824. } else if (setting.type=="dropdown") {
  825. settings[name] = document.getElementById(name).value;
  826. } else if (setting.type=="checkbox_with_colors") {
  827. settings[name] = document.getElementById(name).checked;
  828. let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
  829. colorFields.forEach((colorKey) => {
  830. const idx = colorKey.match(/^color(\d+)Default$/)[1];
  831. const colorName = `${name}_color${idx}`;
  832. const colorValue = document.getElementById(colorName).value;
  833. settings[colorName] = colorValue;
  834. // Set CSS variable on body (so it can be used without a refresh)
  835. document.body.style.setProperty(`--${colorName}`, colorValue);
  836. });
  837. } else {
  838. settings[name] = document.getElementById(name).checked;
  839. }
  840. }
  841. })
  842. console.log("Saving settings ",settings)
  843. await Promise.all(Object.entries(settings).map(([key, value]) => GM.setValue(key, value)));
  844. setTimeout(()=>{
  845. 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)");
  846. }, 1);
  847. // menu.remove();
  848. });
  849.  
  850. // Reset button functionality
  851. document.getElementById("resetSettings").addEventListener("click", async () => {
  852. if (!confirm("Are you sure you want to reset all settings? This will delete all saved data.")) return;
  853. const keys = await GM.listValues();
  854. await Promise.all(keys.map(key => GM.deleteValue(key)));
  855. alert("All settings have been reset.\nRefreshing automatically for the changes to take effect.");
  856. menu.remove();
  857. location.reload();
  858. });
  859.  
  860. // Close button functionality
  861. document.getElementById("closeMenu").addEventListener("click", () => {
  862. menu.remove();
  863. });
  864.  
  865. }
  866.  
  867. function createMarker(element, container, isReply) {
  868. const pageHeight = document.body.scrollHeight;
  869. const offsetTop = element.offsetTop;
  870. const percent = offsetTop / pageHeight;
  871. const top = (percent * 100).toFixed(2);
  872.  
  873. const marker = document.createElement("div");
  874. marker.classList.add("marker");
  875. if (isReply) {
  876. marker.classList.add("alt");
  877. }
  878. marker.style.top = `${top}%`;
  879. marker.dataset.postid = element.id;
  880.  
  881. marker.addEventListener("click", () => {
  882. let elem = element?.previousElementSibling || element;
  883. if (elem) elem.scrollIntoView({ behavior: "instant", block: "start" });
  884. });
  885.  
  886. container.appendChild(marker);
  887. }
  888.  
  889. function recreateScrollMarkers() {
  890. let oldContainer = document.querySelector(".marker-container");
  891. if (oldContainer) {
  892. oldContainer.remove();
  893. }
  894. // Create marker container
  895. const markerContainer = document.createElement("div");
  896. markerContainer.classList.add("marker-container");
  897. document.body.appendChild(markerContainer);
  898. // Match and create markers for "my posts" (matches native)
  899. document.querySelectorAll(".postCell:has(> .yourPost)")
  900. .forEach((elem) => {
  901. createMarker(elem, markerContainer, false);
  902. });
  903.  
  904. // Match and create markers for "replies" (matches native)
  905. document.querySelectorAll(".postCell:has(> .quotesYou)")
  906. .forEach((elem) => {
  907. createMarker(elem, markerContainer, true);
  908. });
  909. }
  910.  
  911. let postCount = 1;
  912. const postIndexLookup = {};
  913. function addPostCount(post, newpost = true) {
  914. // const posts = Array.from(document.querySelectorAll(".innerOP, .divPosts > .postCell"));
  915. if (post.querySelector(".postNum")) {
  916. return;
  917. }
  918.  
  919. const postInfoDiv = post.querySelector(".title");
  920. const posterNameDiv = postInfoDiv.querySelector(".linkName");
  921. const postNumber = postInfoDiv.querySelector(".linkQuote")?.textContent;
  922. if (!postNumber) return;
  923.  
  924. let localCount = postCount;
  925. if (newpost) {
  926. postIndexLookup[postNumber] = localCount;
  927. postCount++;
  928. } else {
  929. //Show cached post count for inlines & hovers
  930. localCount = postIndexLookup[postNumber];
  931. if (!localCount) return;
  932. }
  933.  
  934. let newNode = document.createElement("span");
  935. newNode.innerText = localCount;
  936. newNode.className = "postNum index";
  937. // if (localCount >= Infinity) { //knownBumpLimit
  938. // newNode.style = "color: rgb(255, 4, 4); font-weight: bold;"
  939. // }
  940. postInfoDiv.insertBefore(newNode, posterNameDiv);
  941. let foo = document.createTextNode("\u00A0"); // Non-breaking space
  942. postInfoDiv.insertBefore(foo, posterNameDiv);
  943. }
  944.  
  945. //Open source svg from iconify.design
  946. 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>`;
  947. function filenameFeatures(post) {
  948. const fileNames = post.querySelectorAll(".originalNameLink[href]");
  949.  
  950. //Feature: Distinguish videos, gifs and audio with an icon
  951. //Theres also a mime type on .imgLink
  952. if (settings.showVideoIcons) {
  953. const videoExts = /\.(webm|mp4|mkv|mov|avi|flv|wmv|m4v|gif|apng|mp3|flac|opus|ogg|wav|aac|m4a|wma)$/i;
  954. fileNames.forEach((nameElem) => {
  955. if (videoExts.test(nameElem.download)) {
  956. nameElem.classList.add("lynx-video");
  957. }
  958. });
  959. }
  960.  
  961. //Last feature: Reverse image search buttons
  962. //TODO MAYBE: Make this button open a small menu (like the post menu) instead of multiple buttons. AI can do it in 20 seconds.
  963. // .some() will return true if any value in the dict is true
  964. // So inverting this means no values are true, so return without doing anything
  965. if (!Object.values(settings.reverseSearchOptions).some(Boolean)) {
  966. return;
  967. }
  968.  
  969. const fileNameElements = Array.from(fileNames);
  970. const regex_md5sum = /[0-9a-f]{32}/g;
  971. const regex_pixiv = /(\d+)_p\d+/;
  972. const alt_regex_pixiv = /illust\_(\d+)\_\d+\_\d+/; //Match pixiv images saved using the mobile app (ex. illust_123456_20250530_123456)
  973.  
  974. for (let i = fileNameElements.length-1; i>=0; i--)
  975. {
  976. const nameElem = fileNameElements[i];
  977. const parent = nameElem.parentElement
  978. if (parent.querySelector(".lynxReverseImageSearch")) {
  979. return;
  980. }
  981. const attachmentFileName = nameElem.download;
  982.  
  983. const span = document.createElement("span");
  984. span.classList.add("lynxReverseImageSearch");
  985.  
  986. let searchButtonsAdded = 0
  987.  
  988. let m;
  989. if (settings.reverseSearchOptions.pixiv) {
  990. if ((m = regex_pixiv.exec(attachmentFileName)) !== null || (m = alt_regex_pixiv.exec(attachmentFileName)) !== null) {
  991. span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="pixiv" href="https://pixiv.net/i/${m[1]}">${SVG_SEARCH}</a>`
  992. searchButtonsAdded++;
  993. }
  994. }
  995. //This is 'else if' because these options are mutually exclusive - a filename will never match pixiv AND an md5 hash
  996. //Careful with this insane abuse of conditionals, the order of operations matters (&& is before = without parenthesis)
  997. //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)
  998. else if (settings.reverseSearchOptions.booru && (m = [...attachmentFileName.matchAll(regex_md5sum)]) && m?.length == 1) {
  999. span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="${settings.reverseSearchBooruSite}" href="${SETTINGS_DEFINITIONS['reverseSearchBooruSite']['choices'][settings.reverseSearchBooruSite]}${m[0]}">${SVG_SEARCH}</a>`
  1000. searchButtonsAdded++;
  1001. }
  1002.  
  1003. if (settings.reverseSearchOptions.saucenao) {
  1004. //Logic: for everything thats not supported i.e. not an image use the thumbnail if its available
  1005. //Supported image extensions for Saucenao direct search
  1006. const imageExts = /\.(png|jpe?g|webp|bmp|gif)$/i;
  1007. let validImage = true;
  1008. let useThumbInstead = false;
  1009. let thumbUrl = null;
  1010. if (!imageExts.test(attachmentFileName)) {
  1011. // Not a supported image, try to find a thumbnail
  1012. const uploadCell = parent.closest('.uploadCell');
  1013. if (uploadCell) {
  1014. const thumbImg = uploadCell.querySelector('.imgLink > img');
  1015. const thumbSrc = thumbImg?.getAttribute("src");
  1016. if (thumbImg && thumbSrc?.startsWith('/.media/t_')) {
  1017. useThumbInstead = true;
  1018. thumbUrl = thumbSrc;
  1019. }
  1020. }
  1021. // If no valid thumbnail, don't add saucenao
  1022. if (!useThumbInstead) {
  1023. // Don't add saucenao button for this file
  1024. validImage = false;
  1025. }
  1026. }
  1027.  
  1028. const sauceNaoWrapper = function(ev) {
  1029. ev.preventDefault(); //Prevent <details> node from contracting
  1030. const a = ev.currentTarget;
  1031. if (a.classList?.contains("fetch-awaiting")) {
  1032. //Abort if theres an ongoing fetch to prevent accidental doubleclicks
  1033. return;
  1034. }
  1035.  
  1036. a.classList.add("fetch-awaiting");
  1037. const fetchUrl = useThumbInstead ? thumbUrl : nameElem.href;
  1038. const fetchName = useThumbInstead ? ("thumbnail_" + attachmentFileName) : attachmentFileName;
  1039. fetch(fetchUrl)
  1040. .then(resp => {
  1041. if (!resp.ok) throw new Error("Fetch failed");
  1042. return resp.blob();
  1043. })
  1044. .then(blob => {
  1045. let file = new File([blob], fetchName, {type: blob.type} );
  1046. let dataTransfer = new DataTransfer();
  1047. dataTransfer.items.add(file);
  1048. document.getElementById("saucenao_file_input").files = dataTransfer.files;
  1049. document.getElementById("saucenao_submit").click();
  1050. a.classList.remove("fetch-awaiting");
  1051. })
  1052. .catch(() => {
  1053. a.classList.remove("fetch-awaiting");
  1054. a.classList.add("fetch-failed");
  1055. });
  1056. }
  1057.  
  1058. if (validImage) {
  1059. const a = document.createElement("a");
  1060. a.setAttribute("data-booruname","saucenao")
  1061. // a.innerText = '🔍︎'
  1062. a.innerHTML = SVG_SEARCH;
  1063. a.addEventListener("click", sauceNaoWrapper)
  1064. span.appendChild(a);
  1065. //span.innerHTML += `<a data-booruname='saucenao' onclick=''>🔍︎</a>`
  1066. searchButtonsAdded++;
  1067. }
  1068. }
  1069.  
  1070. if (searchButtonsAdded > 1) {
  1071. span.classList.add("showSearchNames")
  1072. }
  1073.  
  1074. if (searchButtonsAdded > 0) {
  1075. parent.insertAdjacentElement("beforeend", span);
  1076. }
  1077. }
  1078. }
  1079. /*function glowpost() {
  1080. // Create a frequency map to track occurrences of each item
  1081. const list = document.querySelectorAll(".labelId");
  1082. const countMap = Array.from(list).reduce((acc, item) => {
  1083. acc[item.style.backgroundColor] = (acc[item.style.backgroundColor] || 0) + 1;
  1084. return acc;
  1085. }, {});
  1086. // Filter the list to keep only items with a count of 1
  1087. Array.from(list).filter(item => countMap[item.style.backgroundColor] === 1).forEach((item) => {
  1088. item.style.boxShadow = "0 0 15px #26bf47";
  1089. item.title = "This is the first post from this ID.";
  1090. });
  1091. }*/
  1092. var idMap = {};
  1093. const glowpost = function(post, newpost = true) {
  1094. const list = post.querySelectorAll(".labelId");
  1095. const postNumber = post.querySelector(".linkQuote")?.textContent;
  1096. list.forEach((poster) => {
  1097. const bgColor = poster.style.backgroundColor;
  1098. if (newpost && idMap[bgColor] === undefined) {
  1099. idMap[bgColor] = postNumber;
  1100. poster.classList.add("glows");
  1101. poster.title = "This is the first post from this ID.";
  1102. } else if (!newpost && idMap[bgColor] == postNumber) {
  1103. poster.classList.add("glows");
  1104. poster.title = "This is the first post from this ID.";
  1105. }
  1106. });
  1107. }
  1108.  
  1109. const revealSpoilerImages = function(post) {
  1110. const spoilers = post.querySelectorAll(".imgLink > img:is([src='/spoiler.png'],[src*='/custom.spoiler'])");
  1111. // spoilers.forEach(spoiler => {
  1112. // spoiler.classList.add('spoiler-thumb');
  1113. // const parent = spoiler.parentElement;
  1114. // const hrefTokens = parent.href.split("/");
  1115. // const fileNameTokens = hrefTokens[4].split(".");
  1116. // const thumbUrl = `/.media/t_${fileNameTokens[0]}`;
  1117. // spoiler.src = thumbUrl;
  1118. // });
  1119. spoilers.forEach(image => {
  1120. image.classList.add('spoiler-thumb');
  1121.  
  1122. const uploadCell = image.closest('.uploadCell');
  1123. const parent = image.closest('a.imgLink');
  1124. const fileName = parent.href.split("/")[4];
  1125. const dimensionLabel = uploadCell.querySelector('.dimensionLabel');
  1126. const dataFilemime = parent.getAttribute('data-filemime');
  1127. // Set the full image as the thumbnail for images that are 220x220 pixels or smaller.
  1128. // This is a fix for small images because thumbnails are not generated for them.
  1129. // This crap does not apply to GIFs, GIFs always have generated thumbnails.
  1130. if (dimensionLabel && /^image\/.+$/.test(dataFilemime) && !/^image\/gif$/.test(dataFilemime) ) {
  1131. //Split at x or X, split only 2 times, then for each value in the split array convert to integer and assign to const 'dimensions'
  1132. const dimensions = dimensionLabel.textContent.trim().split(/x|×/, 2).map(v => parseInt(v));
  1133. if (dimensions.length === 2 && dimensions[0] <= 220 && dimensions[1] <= 220) {
  1134. image.src = `/.media/${fileName}?`;
  1135. } else {
  1136. image.src = `/.media/t_${fileName.split(".")[0]}`;
  1137. }
  1138. } else {
  1139. image.src = `/.media/t_${fileName.split(".")[0]}`;
  1140. }
  1141. })
  1142. }
  1143.  
  1144. if (settings.spoilerImageType.startsWith("reveal")) {
  1145. addMyStyle("lynx-reveal-spoilerimage",`
  1146. img.spoiler-thumb {
  1147. transition: 0.2s;
  1148. outline: 2px dotted #ff0000ee;
  1149. ${settings.spoilerImageType=="reveal_blur" ? "filter: blur(10px);" : ""}
  1150. }
  1151. img.spoiler-thumb:hover {
  1152. filter: blur(0);
  1153. }
  1154. `)
  1155. }
  1156. // Add functionality to apply the custom spoiler image CSS
  1157. let threadSpoilerFound = false;
  1158. let tsFallbackUsed = false;
  1159. function setThreadSpoiler(post) {
  1160. if (threadSpoilerFound) return;
  1161.  
  1162. let spoilerImageUrl = null;
  1163.  
  1164. //When the option is "threadAlt", fallback to "thread" if "threadAlt" doesn't exist yet.
  1165. if (settings.spoilerImageType == "threadAlt") {
  1166. const altSpoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoilerAlt"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
  1167. spoilerImageUrl = altSpoilerLink ? altSpoilerLink.href : null;
  1168. tsFallbackUsed = false; //stop looking for threadAlt
  1169. }
  1170.  
  1171. if (settings.spoilerImageType == "thread" || (!spoilerImageUrl && !tsFallbackUsed && settings.spoilerImageType == "threadAlt")) {
  1172. const spoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoiler"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
  1173. spoilerImageUrl = spoilerLink ? spoilerLink.href : null;
  1174. if (settings.spoilerImageType == "threadAlt") {
  1175. tsFallbackUsed = true; //Keep looking for threadAlt
  1176. }
  1177. } else if (settings.spoilerImageType == "test") {
  1178. const myArray = [
  1179. 'https://8chan.moe/.media/f76e9657d6b506115ccd0ade73d3d562777a441e4b6bb396610669396ff3032a.png',
  1180. 'https://8chan.moe/.media/1074fdb6eea4ba609910581e7824106736a1bcad446ace1ae0792b231b52cf9a.png',
  1181. 'https://8chan.moe/.media/c32b4de8490d7e77987f0e2a381d5935ffa6fec9b98c78ea7c05bd4381d6f64b.png',
  1182. 'https://8chan.moe/.media/bb225302110d52494ec2bea68693d566acee09767212ce4ee8c0d83d49cfa05b.png'
  1183. ];
  1184. spoilerImageUrl = myArray[Math.floor(Math.random() * myArray.length)];
  1185. addMyStyle("lynx-thread-spoiler-css1", `
  1186. body {
  1187. --spoiler-img: url("${spoilerImageUrl}")
  1188. }
  1189. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]),
  1190. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]) {
  1191. background-image: var(--spoiler-img);
  1192. background-size: cover;
  1193. background-position: center;
  1194. & > img {
  1195. opacity: 0;
  1196. }
  1197. }
  1198. `);
  1199. threadSpoilerFound = true;
  1200. return;
  1201. }
  1202.  
  1203. if (spoilerImageUrl) {
  1204. document.head?.querySelector("#lynx-thread-spoiler-css2")?.remove(); //Remove if the style already exists (from fallback)
  1205. addMyStyle("lynx-thread-spoiler-css2", `
  1206. ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
  1207. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
  1208. background-image: url("${spoilerImageUrl}");
  1209. background-size: cover;
  1210. background-position: center;
  1211. outline: dashed 2px #ff000090;
  1212. & > img {
  1213. opacity: 0;
  1214. }
  1215. }
  1216. `);
  1217. if (!tsFallbackUsed) {
  1218. threadSpoilerFound = true;
  1219. }
  1220. }
  1221. }
  1222.  
  1223. if (settings.spoilerImageType=="kachina") {
  1224. addMyStyle("lynx-kachinaSpoilers",`
  1225. ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
  1226. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
  1227. background-size: cover;
  1228. background-position: center;
  1229. margin-right:5px;
  1230. background-image: url("data:image/jpeg;base64,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");
  1231. & > img {
  1232. opacity: 0;
  1233. }
  1234. }
  1235. `)
  1236. }
  1237.  
  1238. function iterateAllPosts() {
  1239. //Get ALL posts (this does NOT include inlined posts and hovered posts)
  1240. const allPosts = document.querySelectorAll("#divThreads > .opCell > .innerOP, .divPosts > .postCell");
  1241. allPosts.forEach((post) => {
  1242. iterateSinglePost(post, true);
  1243. });
  1244. }
  1245.  
  1246. /**
  1247. * Processes a single post element.
  1248. *
  1249. * @param {HTMLElement} post - The post here can be an .innerPost or one of its containers
  1250. * @param {boolean} newpost - True if this is a new post in the thread (i.e. not a tooltip or inline)
  1251. */
  1252. function iterateSinglePost(post, newpost = false) {
  1253. // console.log("Lynx-- processing post", {post}, {newpost}, {batching});
  1254. filenameFeatures(post);
  1255. if (settings.glowFirstPostByID)
  1256. glowpost(post, newpost);
  1257. if (settings.spoilerImageType.startsWith("reveal"))
  1258. revealSpoilerImages(post);
  1259. if (settings.showPostIndex)
  1260. addPostCount(post, newpost);
  1261.  
  1262. //Run only if its a new post in the thread
  1263. if (newpost) {
  1264. if (settings.spoilerImageType=="thread" || settings.spoilerImageType=="threadAlt")
  1265. setThreadSpoiler(post);
  1266.  
  1267. //Below functions still have to iterate all posts, do these last and only when necessary.
  1268. //These are now manually ran outside this function for performance reasons.
  1269. // if (settings.showScrollbarMarkers)
  1270. // recreateScrollMarkers();
  1271. }
  1272. }
  1273.  
  1274. //ANYTHING BELOW ONLY RUNS ON THREAD PAGES (if (isThread))
  1275. //ANYTHING BELOW ONLY RUNS ON THREAD PAGES (if (isThread))
  1276. //99% of above are functions. They can be ignored.
  1277. if (isThread) {
  1278. if (settings.addKeyboardHandlers) {
  1279. document.getElementById("qrbody")?.addEventListener("keydown", replyKeyboardShortcuts);
  1280. document.getElementById("quick-reply")?.addEventListener('keydown',function(ev) {
  1281. if (ev.key == "Escape") {
  1282. document.getElementById("quick-reply")?.querySelector(".close-btn").click();
  1283. }
  1284. })
  1285. }
  1286. //I'm not sure who would ever want this on but I'm making it an option anyways
  1287. if (settings.preserveQuickReply===false) {
  1288. document.getElementById("quick-reply")?.querySelector(".close-btn")?.addEventListener("click", function(ev){
  1289. document.getElementById("qrbody").value = "";
  1290. });
  1291. //This doesn't replace the built in onclick but adds to it so the original onclick will still bring up the qr
  1292. document.getElementById("replyButton")?.addEventListener("click", function(ev){
  1293. ev.preventDefault();
  1294. const qrBody = document.getElementById("qrbody");
  1295. if (qrBody) {
  1296. qrBody.value = "";
  1297. qrBody?.focus();
  1298. }
  1299. });
  1300. }
  1301. if (settings.reverseSearchOptions.saucenao) {
  1302. //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
  1303. const formm =`
  1304. <form target="_blank" action="https://saucenao.com/search.php" method="POST" enctype="multipart/form-data" style="display:none">
  1305. <input type="file" name="file" size="50" id='saucenao_file_input'>
  1306. <input type="submit" accesskey="s" value="get sauce" id='saucenao_submit'>
  1307. </form>`
  1308. document.body.insertAdjacentHTML('beforeend', formm);
  1309. }
  1310. //Start running and observing
  1311. iterateAllPosts();
  1312. //Delay slow actions to let the page finish loading first.
  1313. if (settings.showScrollbarMarkers) {
  1314. setTimeout(() => recreateScrollMarkers(), 1);
  1315. }
  1316. //Observe posts and all their children
  1317. const observer = new MutationObserver((mt_callback) => {
  1318. let foundNewPost = false;
  1319. mt_callback.forEach(mut => {
  1320. if (mut.type == "childList" && mut.addedNodes?.length > 0) {
  1321. //console.log("MutationObserver!!!");
  1322. mut.addedNodes.forEach(node => {
  1323. //New posts, new inlined posts, new hovered posts all contain .innerPost and are always in a div container.
  1324. //New posts are div.postCell and new inlines are div.inlineQuote
  1325. if (node.tagName === "DIV") {
  1326. // console.log("lynx ~ observer:", {node}, {mut});
  1327. if (node.classList.contains("postCell")) {
  1328. foundNewPost = true;
  1329. iterateSinglePost(node, true);
  1330. } else if (node.classList.contains("inlineQuote")) {
  1331. iterateSinglePost(node, false);
  1332. }
  1333. }
  1334. });
  1335. }
  1336. });
  1337. //Manually run all remaining slow actions here
  1338. if (foundNewPost && settings.showScrollbarMarkers) {
  1339. recreateScrollMarkers();
  1340. }
  1341. });
  1342. observer.observe(document.querySelector(".divPosts"), {childList: true, subtree: true});
  1343.  
  1344. //Observe the hover tooltip (ignore everything else)
  1345. const toolObserver = new MutationObserver((mutationsList) => {
  1346. for (const mutation of mutationsList) {
  1347. if (mutation.type === 'childList') {
  1348. mutation.addedNodes.forEach(node => {
  1349. if (node.tagName === "DIV" && node.matches(".innerPost, .innerOP")) {
  1350. //New hover tooltip found
  1351. iterateSinglePost(node, false);
  1352. }
  1353. });
  1354. }
  1355. }
  1356. });
  1357. const quoteTooltip = document.body?.querySelector(":scope > div.quoteTooltip");
  1358. if (quoteTooltip) {
  1359. toolObserver.observe(quoteTooltip, {childList: true});
  1360. }
  1361. }
  1362. } //End of runAfterDom()
  1363.  
  1364. //Starting runAfterDom when the document is ready
  1365. waitForDom(runAfterDom);
  1366. })();