8chanSS

Userscript to style 8chan

当前为 2025-04-27 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name 8chanSS
  3. // @version 1.34.0
  4. // @namespace 8chanSS
  5. // @description Userscript to style 8chan
  6. // @author otakudude
  7. // @minGMVer 4.3
  8. // @minFFVer 121
  9. // @license MIT; https://github.com/otacoo/8chanSS/blob/main/LICENSE
  10. // @match *://8chan.moe/*
  11. // @match *://8chan.se/*
  12. // @exclude *://8chan.moe/login.html
  13. // @exclude *://8chan.se/login.html
  14. // @grant GM.getValue
  15. // @grant GM.setValue
  16. // @grant GM.deleteValue
  17. // @grant GM.listValues
  18. // @run-at document-start
  19. // ==/UserScript==
  20.  
  21. (function () {
  22. const userTheme = localStorage.selectedTheme;
  23. if (!userTheme) return;
  24. const swapTheme = () => {
  25. const themeLink = Array.from(
  26. document.getElementsByTagName("link")
  27. ).find(
  28. (link) =>
  29. link.rel === "stylesheet" &&
  30. /\/\.static\/css\/themes\//.test(link.href)
  31. );
  32. if (themeLink) {
  33. const themeBase = themeLink.href.replace(/\/[^\/]+\.css$/, "/");
  34. themeLink.href = themeBase + userTheme + ".css";
  35. }
  36. };
  37. onReady(swapTheme);
  38. onReady(function () {
  39. const themeSelector = document.getElementById("themeSelector");
  40. if (themeSelector) {
  41. for (let i = 0; i < themeSelector.options.length; i++) {
  42. if (
  43. themeSelector.options[i].value === userTheme ||
  44. themeSelector.options[i].text === userTheme
  45. ) {
  46. themeSelector.selectedIndex = i;
  47. break;
  48. }
  49. }
  50. }
  51. });
  52. })();
  53. (function () {
  54. function updateLocalStorage(removeKeys = [], setMap = {}) {
  55. for (const key of removeKeys) {
  56. localStorage.removeItem(key);
  57. }
  58. for (const [key, value] of Object.entries(setMap)) {
  59. localStorage.setItem(key, value);
  60. }
  61. }
  62.  
  63. try {
  64. updateLocalStorage(
  65. ["hoveringImage"],
  66. {}
  67. );
  68. } catch (e) {
  69. }
  70. })();
  71. function onReady(fn) {
  72. if (document.readyState === "loading") {
  73. document.addEventListener("DOMContentLoaded", fn, { once: true });
  74. } else {
  75. fn();
  76. }
  77. }
  78. onReady(async function () {
  79. const scriptSettings = {
  80. site: {
  81. alwaysShowTW: { label: "Pin Thread Watcher", default: false },
  82. enableHeaderCatalogLinks: {
  83. label: "Header Catalog Links",
  84. default: true,
  85. subOptions: {
  86. openInNewTab: {
  87. label: "Always open in new tab",
  88. default: false,
  89. }
  90. }
  91. },
  92. enableBottomHeader: { label: "Bottom Header", default: false },
  93. enableScrollSave: {
  94. label: "Save Scroll Position",
  95. default: true,
  96. subOptions: {
  97. showUnreadLine: {
  98. label: "Show Unread Line",
  99. default: true,
  100. }
  101. }
  102. },
  103. enableScrollArrows: { label: "Show Up/Down Arrows", default: false },
  104. hoverVideoVolume: { label: "Hover Media Volume (0-100%)", default: 50, type: "number", min: 0, max: 100 }
  105. },
  106. threads: {
  107. enableThreadImageHover: { label: "Thread Image Hover", default: true },
  108. enableNestedReplies: { label: "Enabled Nested Replies", default: false },
  109. enableStickyQR: { label: "Enable Sticky Quick Reply", default: false },
  110. fadeQuickReply: { label: "Fade Quick Reply", default: false },
  111. watchThreadOnReply: { label: "Watch Thread on Reply", default: true },
  112. scrollToBottom: { label: "Don't Scroll to Bottom on Reply", default: true },
  113. beepOnYou: { label: "Beep on (You)", default: false },
  114. notifyOnYou: {
  115. label: "Notify when (You) (!)",
  116. default: true,
  117. subOptions: {
  118. customMessage: {
  119. label: "Custom Notification",
  120. default: "",
  121. type: "text",
  122. maxLength: 8
  123. }
  124. }
  125. },
  126. blurSpoilers: {
  127. label: "Blur Spoilers",
  128. default: false,
  129. subOptions: {
  130. removeSpoilers: {
  131. label: "Remove Spoilers",
  132. default: false
  133. }
  134. }
  135. },
  136. deleteSavedName: { label: "Delete Name Checkbox", default: true }
  137. },
  138. catalog: {
  139. enableCatalogImageHover: { label: "Catalog Image Hover", default: true },
  140. enableThreadHiding: { label: "Enable Thread Hiding", default: false }
  141. },
  142. styling: {
  143. _siteTitle: { type: "title", label: ":: Site Styling" },
  144. _stylingSection1: { type: "separator" },
  145. hideAnnouncement: { label: "Hide Announcement", default: false },
  146. hidePanelMessage: { label: "Hide Panel Message", default: false },
  147. hidePostingForm: {
  148. label: "Hide Posting Form",
  149. default: false,
  150. subOptions: {
  151. showCatalogForm: {
  152. label: "Don't Hide in Catalog",
  153. default: false
  154. }
  155. }
  156. },
  157. hideBanner: { label: "Hide Board Banners", default: false },
  158. hideDefaultBL: { label: "Hide Default Board List", default: true },
  159. _threadTitle: { type: "title", label: ":: Thread Styling" },
  160. _stylingSection2: { type: "separator" },
  161. highlightOnYou: { label: "Highlight (You) posts", default: true },
  162. enableFitReplies: { label: "Fit Replies", default: false },
  163. enableSidebar: {
  164. label: "Enable Sidebar",
  165. default: false,
  166. subOptions: {
  167. leftSidebar: {
  168. label: "Sidebar on Left",
  169. default: false
  170. },
  171. },
  172. },
  173. threadHideCloseBtn: { label: "Hide Inline Close Button", default: false },
  174. hideHiddenPostStub: { label: "Hide Stubs of Hidden Posts", default: false, }
  175. },
  176. };
  177. const flatSettings = {};
  178. function flattenSettings() {
  179. Object.keys(scriptSettings).forEach((category) => {
  180. Object.keys(scriptSettings[category]).forEach((key) => {
  181. flatSettings[key] = scriptSettings[category][key];
  182. if (!scriptSettings[category][key].subOptions) return;
  183. Object.keys(scriptSettings[category][key].subOptions).forEach(
  184. (subKey) => {
  185. const fullKey = `${key}_${subKey}`;
  186. flatSettings[fullKey] =
  187. scriptSettings[category][key].subOptions[subKey];
  188. }
  189. );
  190. });
  191. });
  192. }
  193. flattenSettings();
  194. async function getSetting(key) {
  195. if (!flatSettings[key]) {
  196. console.warn(`Setting key not found: ${key}`);
  197. return false;
  198. }
  199. let val = await GM.getValue("8chanSS_" + key, null);
  200. if (val === null) return flatSettings[key].default;
  201. if (flatSettings[key].type === "number") return Number(val);
  202. if (flatSettings[key].type === "text") return String(val).replace(/[<>"']/g, "").slice(0, flatSettings[key].maxLength || 32);
  203. return val === "true";
  204. }
  205.  
  206. async function setSetting(key, value) {
  207. await GM.setValue("8chanSS_" + key, String(value));
  208. }
  209. async function featureCssClassToggles() {
  210. document.documentElement.classList.add("8chanSS");
  211. const enableSidebar = await getSetting("enableSidebar");
  212. const enableSidebar_leftSidebar = await getSetting("enableSidebar_leftSidebar");
  213.  
  214. const classToggles = {
  215. enableFitReplies: "fit-replies",
  216. enableSidebar_leftSidebar: "ss-leftsidebar",
  217. enableStickyQR: "sticky-qr",
  218. fadeQuickReply: "fade-qr",
  219. enableBottomHeader: "bottom-header",
  220. hideHiddenPostStub: "hide-stub",
  221. hideBanner: "disable-banner",
  222. hidePostingForm: "hide-posting-form",
  223. hidePostingForm_showCatalogForm: "show-catalog-form",
  224. hideDefaultBL: "hide-defaultBL",
  225. hideAnnouncement: "hide-announcement",
  226. hidePanelMessage: "hide-panelmessage",
  227. highlightOnYou: "highlight-you",
  228. threadHideCloseBtn: "hide-close-btn"
  229. };
  230. if (enableSidebar && !enableSidebar_leftSidebar) {
  231. document.documentElement.classList.add("ss-sidebar");
  232. } else {
  233. document.documentElement.classList.remove("ss-sidebar");
  234. }
  235. for (const [settingKey, className] of Object.entries(classToggles)) {
  236. if (await getSetting(settingKey)) {
  237. document.documentElement.classList.add(className);
  238. } else {
  239. document.documentElement.classList.remove(className);
  240. }
  241. }
  242. const urlClassMap = [
  243. { pattern: /\/catalog\.html$/i, className: "is-catalog" },
  244. { pattern: /\/res\/[^/]+\.html$/i, className: "is-thread" },
  245. { pattern: /\/[^/]+\/(#)?$/i, className: "is-index" },
  246. ];
  247. const currentPath = window.location.pathname.toLowerCase() + window.location.hash;
  248. urlClassMap.forEach(({ pattern, className }) => {
  249. if (pattern.test(currentPath)) {
  250. document.documentElement.classList.add(className);
  251. } else {
  252. document.documentElement.classList.remove(className);
  253. }
  254. });
  255. }
  256. featureCssClassToggles();
  257. async function featureSidebar() {
  258. const enableSidebar = await getSetting("enableSidebar");
  259. const enableSidebar_leftSidebar = await getSetting("enableSidebar_leftSidebar");
  260.  
  261. const mainPanel = document.getElementById("mainPanel");
  262. if (!mainPanel) return;
  263.  
  264. if (enableSidebar && enableSidebar_leftSidebar) {
  265. mainPanel.style.marginLeft = "19rem";
  266. mainPanel.style.marginRight = "0";
  267. } else if (enableSidebar) {
  268. mainPanel.style.marginRight = "19rem";
  269. mainPanel.style.marginLeft = "0";
  270. } else {
  271. mainPanel.style.marginRight = "0";
  272. mainPanel.style.marginLeft = "0";
  273. }
  274. }
  275. onReady(featureSidebar);
  276. const themeSelector = document.getElementById("themesBefore");
  277. let link = null;
  278. let bracketSpan = null;
  279. if (themeSelector) {
  280. bracketSpan = document.createElement("span");
  281. bracketSpan.textContent = "] [ ";
  282. link = document.createElement("a");
  283. link.id = "8chanSS-icon";
  284. link.href = "#";
  285. link.textContent = "8chanSS";
  286. link.style.fontWeight = "bold";
  287.  
  288. themeSelector.parentNode.insertBefore(
  289. bracketSpan,
  290. themeSelector.nextSibling
  291. );
  292. themeSelector.parentNode.insertBefore(link, bracketSpan.nextSibling);
  293. }
  294. function createShortcutsTab() {
  295. const container = document.createElement("div");
  296. const title = document.createElement("h3");
  297. title.textContent = "Keyboard Shortcuts";
  298. title.style.margin = "0 0 15px 0";
  299. title.style.fontSize = "16px";
  300. container.appendChild(title);
  301. const table = document.createElement("table");
  302. table.style.width = "100%";
  303. table.style.borderCollapse = "collapse";
  304. const tableStyles = {
  305. th: {
  306. textAlign: "left",
  307. padding: "8px 5px",
  308. borderBottom: "1px solid #444",
  309. fontSize: "14px",
  310. fontWeight: "bold",
  311. },
  312. td: {
  313. padding: "8px 5px",
  314. borderBottom: "1px solid #333",
  315. fontSize: "13px",
  316. },
  317. kbd: {
  318. background: "#333",
  319. border: "1px solid #555",
  320. borderRadius: "3px",
  321. padding: "2px 5px",
  322. fontSize: "12px",
  323. fontFamily: "monospace",
  324. },
  325. };
  326. const headerRow = document.createElement("tr");
  327. const shortcutHeader = document.createElement("th");
  328. shortcutHeader.textContent = "Shortcut";
  329. Object.assign(shortcutHeader.style, tableStyles.th);
  330. headerRow.appendChild(shortcutHeader);
  331.  
  332. const actionHeader = document.createElement("th");
  333. actionHeader.textContent = "Action";
  334. Object.assign(actionHeader.style, tableStyles.th);
  335. headerRow.appendChild(actionHeader);
  336.  
  337. table.appendChild(headerRow);
  338. const shortcuts = [
  339. { keys: ["Ctrl", "F1"], action: "Open 8chanSS settings" },
  340. { keys: ["Ctrl", "Q"], action: "Toggle Quick Reply" },
  341. { keys: ["Ctrl", "Enter"], action: "Submit post" },
  342. { keys: ["Escape"], action: "Clear textarea and hide Quick Reply" },
  343. { keys: ["ALT", "W"], action: "Watch Thread" },
  344. { keys: ["SHIFT", "M1"], action: "Hide Thread in Catalog" },
  345. { keys: ["CTRL", "UP/DOWN"], action: "Scroll between Your Replies" },
  346. { keys: ["CTRL", "SHIFT", "UP/DOWN"], action: "Scroll between Replies to You" },
  347. { keys: ["Ctrl", "B"], action: "Bold text" },
  348. { keys: ["Ctrl", "I"], action: "Italic text" },
  349. { keys: ["Ctrl", "U"], action: "Underline text" },
  350. { keys: ["Ctrl", "S"], action: "Spoiler text" },
  351. { keys: ["Ctrl", "D"], action: "Doom text" },
  352. { keys: ["Ctrl", "M"], action: "Moe text" },
  353. { keys: ["Alt", "C"], action: "Code block" },
  354. ];
  355. shortcuts.forEach((shortcut) => {
  356. const row = document.createElement("tr");
  357. const shortcutCell = document.createElement("td");
  358. Object.assign(shortcutCell.style, tableStyles.td);
  359. shortcut.keys.forEach((key, index) => {
  360. const kbd = document.createElement("kbd");
  361. kbd.textContent = key;
  362. Object.assign(kbd.style, tableStyles.kbd);
  363. shortcutCell.appendChild(kbd);
  364. if (index < shortcut.keys.length - 1) {
  365. const plus = document.createTextNode(" + ");
  366. shortcutCell.appendChild(plus);
  367. }
  368. });
  369.  
  370. row.appendChild(shortcutCell);
  371. const actionCell = document.createElement("td");
  372. actionCell.textContent = shortcut.action;
  373. Object.assign(actionCell.style, tableStyles.td);
  374. row.appendChild(actionCell);
  375.  
  376. table.appendChild(row);
  377. });
  378.  
  379. container.appendChild(table);
  380. const note = document.createElement("p");
  381. note.textContent =
  382. "Text formatting shortcuts work when text is selected or when inserting at cursor position.";
  383. note.style.fontSize = "12px";
  384. note.style.marginTop = "15px";
  385. note.style.opacity = "0.7";
  386. note.style.fontStyle = "italic";
  387. container.appendChild(note);
  388.  
  389. return container;
  390. }
  391. const currentPath = window.location.pathname.toLowerCase();
  392. const currentHost = window.location.hostname.toLowerCase();
  393.  
  394. let css = "";
  395.  
  396. if (/^8chan\.(se|moe)$/.test(currentHost)) {
  397. css += ":not(.is-catalog) body{margin:0}#sideCatalogDiv{z-index:200;background:var(--background-gradient)}#navFadeEnd,#navFadeMid,:root.hide-announcement #dynamicAnnouncement,:root.hide-close-btn .inlineQuote>.innerPost>.postInfo.title>a:first-child,:root.hide-panelmessage #panelMessage,:root.hide-posting-form #postingForm{display:none}:root.hide-defaultBL #navTopBoardsSpan{display:none!important}:root.is-catalog.show-catalog-form #postingForm{display:block!important}footer{visibility:hidden;height:0}nav.navHeader{z-index:300}:not(:root.bottom-header) .navHeader{box-shadow:0 1px 2px rgba(0,0,0,.15)}:root.bottom-header nav.navHeader{top:auto!important;bottom:0!important;box-shadow:0 -1px 2px rgba(0,0,0,.15)}:root.fit-replies :not(.hidden).innerPost{margin-left:10px;display:flow-root}:root.fit-replies :not(.hidden,.inlineQuote).innerPost{margin-left:0}:root.fit-replies .quoteTooltip{display:table!important}#watchedMenu .floatingContainer{overflow-x:hidden;overflow-wrap:break-word}.watchedCellLabel a::before{content:attr(data-board);color:#aaa;margin-right:4px;font-weight:700}.watchButton.watched-active::before{color:#dd003e!important}#watchedMenu{font-size:smaller;padding:5px!important;box-shadow:-3px 3px 2px 0 rgba(0,0,0,.19)}#watchedMenu,#watchedMenu .floatingContainer{min-width:200px}.watchedNotification::before{padding-right:2px}#watchedMenu .floatingContainer{scrollbar-width:thin;scrollbar-color:var(--link-color) var(--contrast-color)}.scroll-arrow-btn{position:fixed;right:50px;width:36px;height:35px;background:#222;color:#fff;border:none;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,.18);font-size:22px;cursor:pointer;opacity:.7;z-index:800;display:flex;align-items:center;justify-content:center;transition:opacity .2s,background .2s}:root:not(.is-index,.is-catalog).ss-sidebar .scroll-arrow-btn{right:330px!important}.scroll-arrow-btn:hover{opacity:1;background:#444}#scroll-arrow-up{bottom:80px}#scroll-arrow-down{bottom:32px}.innerUtility.top{margin-top:2em;background-color:transparent!important;color:var(--link-color)!important}.innerUtility.top a{color:var(--link-color)!important}.bumpLockIndicator::after{padding-right:3px}.floatingMenu.focused{z-index:305!important}.ss-chevron{transition:transform .2s;margin-left:6px;font-size:12px;display:inline-block}a.imgLink[data-filemime^='audio/'],a.originalNameLink[href$='.m4a'],a.originalNameLink[href$='.mp3'],a.originalNameLink[href$='.ogg'],a.originalNameLink[href$='.wav']{position:relative}.audio-preview-indicator{display:none;position:absolute;background:rgba(0,0,0,.7);color:#fff;padding:5px;font-size:12px;border-radius:3px;z-index:1000;left:0;top:0;white-space:nowrap;pointer-events:none}a.originalNameLink:hover .audio-preview-indicator,a[data-filemime^='audio/']:hover .audio-preview-indicator{display:block}";
  398. }
  399. if (/\/res\/[^/]+\.html$/.test(currentPath)) {
  400. css += ":root.sticky-qr #quick-reply{display:block;top:auto!important;bottom:0}:root.sticky-qr.ss-sidebar #quick-reply{left:auto!important;right:0!important}:root.sticky-qr.ss-leftsidebar #quick-reply{left:0!important;right:auto!important}:root.sticky-qr #qrbody{resize:vertical;max-height:50vh;height:130px}#selectedDivQr,:root.sticky-qr #selectedDiv{display:inline-flex;overflow:scroll hidden;max-width:300px}#qrbody{min-width:300px}:root.bottom-header #quick-reply{bottom:28px!important}:root.fade-qr #quick-reply{padding:0;opacity:.7;transition:opacity .3s ease}:root.fade-qr #quick-reply:focus-within,:root.fade-qr #quick-reply:hover{opacity:1}.floatingMenu{padding:0!important}#qrFilesBody{max-width:300px}#unread-line{height:2px;border:none!important;pointer-events:none!important;background-image:linear-gradient(to left,rgba(185,185,185,.2),var(--text-color),rgba(185,185,185,.2));margin:-3px auto 0 auto;width:60%}:root.disable-banner #bannerImage{display:none}:root.ss-sidebar #bannerImage{width:19rem;right:0;position:fixed;top:26px}:root.ss-sidebar.bottom-header #bannerImage{top:0!important}:root.ss-leftsidebar #bannerImage{width:19rem;left:0;position:fixed;top:26px}:root.ss-leftsidebar.bottom-header #bannerImage{top:0!important}.quoteTooltip{z-index:999}.nestedQuoteLink{text-decoration:underline dashed!important}:root.hide-stub .unhideButton{display:none}.quoteTooltip .innerPost{overflow:hidden;box-shadow:-3px 3px 2px 0 rgba(0,0,0,.19)}.reply-inlined{text-decoration:underline dashed!important;text-underline-offset:2px}.target-highlight{background:var(--marked-color);border-color:var(--marked-border-color);color:var(--marked-text-color)}:root.highlight-you .innerPost:has(> .postInfo.title > .youName){border-left:dashed #68b723 3px}:root.highlight-you .innerPost:not(:has(> .postInfo.title > .youName)):has(.divMessage > .quoteLink.you){border-left:solid #dd003e 3px}.originalNameLink{display:inline;overflow-wrap:anywhere;white-space:normal}.multipleUploads .uploadCell:not(.expandedCell){max-width:215px}.imgExpanded,video{max-height:90vh!important;object-fit:contain;width:auto!important}.postCell::before{display:inline!important;height:auto!important}";
  401. }
  402. if (/\/catalog\.html$/.test(currentPath)) {
  403. css += "#dynamicAnnouncement{display:none}#postingForm{margin:2em auto}";
  404. }
  405.  
  406. if (!document.getElementById('8chSS')) {
  407. const style = document.createElement('style');
  408. style.id = '8chSS';
  409. style.textContent = css;
  410. document.head.appendChild(style);
  411. }
  412.  
  413. if (await getSetting("enableScrollSave")) {
  414. featureSaveScroll();
  415. }
  416. if (await getSetting("watchThreadOnReply")) {
  417. featureWatchThreadOnReply();
  418. }
  419. if (await getSetting("blurSpoilers")) {
  420. featureBlurSpoilers();
  421. }
  422. if (await getSetting("enableHeaderCatalogLinks")) {
  423. featureHeaderCatalogLinks();
  424. }
  425. if (await getSetting("deleteSavedName")) {
  426. featureDeleteNameCheckbox();
  427. }
  428. if (await getSetting("enableScrollArrows")) {
  429. featureScrollArrows();
  430. }
  431. if (await getSetting("alwaysShowTW")) {
  432. featureAlwaysShowTW();
  433. }
  434. if (await getSetting("scrollToBottom")) {
  435. preventFooterScrollIntoView();
  436. }
  437. if (await getSetting("enableThreadHiding")) {
  438. featureCatalogThreadHideShortcut();
  439. }
  440. if (await getSetting("enableNestedReplies")) {
  441. localStorage.setItem("inlineReplies", "true");
  442. featureNestedReplies();
  443. }
  444. async function initImageHover() {
  445. const isCatalogPage = /\/catalog\.html$/.test(window.location.pathname.toLowerCase());
  446. let enabled = false;
  447. if (isCatalogPage) {
  448. enabled = await getSetting("enableCatalogImageHover");
  449. } else {
  450. enabled = await getSetting("enableThreadImageHover");
  451. }
  452. if (enabled) {
  453. featureImageHover();
  454. }
  455. }
  456. initImageHover();
  457. async function createSettingsMenu() {
  458. let menu = document.getElementById("8chanSS-menu");
  459. if (menu) return menu;
  460. menu = document.createElement("div");
  461. menu.id = "8chanSS-menu";
  462. menu.style.position = "fixed";
  463. menu.style.top = "4rem";
  464. menu.style.left = "20rem";
  465. menu.style.zIndex = "99999";
  466. menu.style.background = "#222";
  467. menu.style.color = "#fff";
  468. menu.style.padding = "0";
  469. menu.style.borderRadius = "8px";
  470. menu.style.boxShadow = "0 4px 16px rgba(0,0,0,0.25)";
  471. menu.style.display = "none";
  472. menu.style.minWidth = "220px";
  473. menu.style.width = "100%";
  474. menu.style.maxWidth = "450px";
  475. menu.style.fontFamily = "sans-serif";
  476. menu.style.userSelect = "none";
  477. let isDragging = false,
  478. dragOffsetX = 0,
  479. dragOffsetY = 0;
  480. const header = document.createElement("div");
  481. header.style.display = "flex";
  482. header.style.justifyContent = "space-between";
  483. header.style.alignItems = "center";
  484. header.style.marginBottom = "0";
  485. header.style.cursor = "move";
  486. header.style.background = "#333";
  487. header.style.padding = "5px 18px 5px";
  488. header.style.borderTopLeftRadius = "8px";
  489. header.style.borderTopRightRadius = "8px";
  490. header.addEventListener("mousedown", function (e) {
  491. isDragging = true;
  492. const rect = menu.getBoundingClientRect();
  493. dragOffsetX = e.clientX - rect.left;
  494. dragOffsetY = e.clientY - rect.top;
  495. document.body.style.userSelect = "none";
  496. });
  497. document.addEventListener("mousemove", function (e) {
  498. if (!isDragging) return;
  499. let newLeft = e.clientX - dragOffsetX;
  500. let newTop = e.clientY - dragOffsetY;
  501. const menuRect = menu.getBoundingClientRect();
  502. const menuWidth = menuRect.width;
  503. const menuHeight = menuRect.height;
  504. const viewportWidth = window.innerWidth;
  505. const viewportHeight = window.innerHeight;
  506. newLeft = Math.max(0, Math.min(newLeft, viewportWidth - menuWidth));
  507. newTop = Math.max(0, Math.min(newTop, viewportHeight - menuHeight));
  508. menu.style.left = newLeft + "px";
  509. menu.style.top = newTop + "px";
  510. menu.style.right = "auto";
  511. });
  512. document.addEventListener("mouseup", function () {
  513. isDragging = false;
  514. document.body.style.userSelect = "";
  515. });
  516. const title = document.createElement("span");
  517. title.textContent = "8chanSS Settings";
  518. title.style.fontWeight = "bold";
  519. header.appendChild(title);
  520.  
  521. const closeBtn = document.createElement("button");
  522. closeBtn.textContent = "✕";
  523. closeBtn.style.background = "none";
  524. closeBtn.style.border = "none";
  525. closeBtn.style.color = "#fff";
  526. closeBtn.style.fontSize = "18px";
  527. closeBtn.style.cursor = "pointer";
  528. closeBtn.style.marginLeft = "10px";
  529. closeBtn.addEventListener("click", () => {
  530. menu.style.display = "none";
  531. });
  532. header.appendChild(closeBtn);
  533.  
  534. menu.appendChild(header);
  535. const tabNav = document.createElement("div");
  536. tabNav.style.display = "flex";
  537. tabNav.style.borderBottom = "1px solid #444";
  538. tabNav.style.background = "#2a2a2a";
  539. const tabContent = document.createElement("div");
  540. tabContent.style.padding = "15px 16px";
  541. tabContent.style.maxHeight = "60vh";
  542. tabContent.style.overflowY = "auto";
  543. const tempSettings = {};
  544. await Promise.all(
  545. Object.keys(flatSettings).map(async (key) => {
  546. tempSettings[key] = await getSetting(key);
  547. })
  548. );
  549. const tabs = {
  550. site: {
  551. label: "Site",
  552. content: createTabContent("site", tempSettings),
  553. },
  554. threads: {
  555. label: "Threads",
  556. content: createTabContent("threads", tempSettings),
  557. },
  558. catalog: {
  559. label: "Catalog",
  560. content: createTabContent("catalog", tempSettings),
  561. },
  562. styling: {
  563. label: "Style",
  564. content: createTabContent("styling", tempSettings),
  565. },
  566. shortcuts: {
  567. label: "⌨️",
  568. content: createShortcutsTab(),
  569. },
  570. };
  571. Object.keys(tabs).forEach((tabId, index, arr) => {
  572. const tab = tabs[tabId];
  573. const tabButton = document.createElement("button");
  574. tabButton.textContent = tab.label;
  575. tabButton.dataset.tab = tabId;
  576. tabButton.style.background = index === 0 ? "#333" : "transparent";
  577. tabButton.style.border = "none";
  578. tabButton.style.borderRight = "1px solid #444";
  579. tabButton.style.color = "#fff";
  580. tabButton.style.padding = "8px 15px";
  581. tabButton.style.margin = "5px 0 0 0";
  582. tabButton.style.cursor = "pointer";
  583. tabButton.style.flex = "1";
  584. tabButton.style.fontSize = "14px";
  585. tabButton.style.transition = "background 0.2s";
  586. if (index === 0) {
  587. tabButton.style.borderTopLeftRadius = "8px";
  588. tabButton.style.margin = "5px 0 0 5px";
  589. }
  590. if (index === arr.length - 1) {
  591. tabButton.style.borderTopRightRadius = "8px";
  592. tabButton.style.margin = "5px 5px 0 0";
  593. tabButton.style.borderRight = "none";
  594. }
  595.  
  596. tabButton.addEventListener("click", () => {
  597. Object.values(tabs).forEach((t) => {
  598. t.content.style.display = "none";
  599. });
  600. tab.content.style.display = "block";
  601. tabNav.querySelectorAll("button").forEach((btn) => {
  602. btn.style.background = "transparent";
  603. });
  604. tabButton.style.background = "#333";
  605. });
  606.  
  607. tabNav.appendChild(tabButton);
  608. });
  609.  
  610. menu.appendChild(tabNav);
  611. Object.values(tabs).forEach((tab, index) => {
  612. tab.content.style.display = index === 0 ? "block" : "none";
  613. tabContent.appendChild(tab.content);
  614. });
  615.  
  616. menu.appendChild(tabContent);
  617. const buttonContainer = document.createElement("div");
  618. buttonContainer.style.display = "flex";
  619. buttonContainer.style.gap = "10px";
  620. buttonContainer.style.padding = "0 18px 15px";
  621. const saveBtn = document.createElement("button");
  622. saveBtn.textContent = "Save";
  623. saveBtn.style.background = "#4caf50";
  624. saveBtn.style.color = "#fff";
  625. saveBtn.style.border = "none";
  626. saveBtn.style.borderRadius = "4px";
  627. saveBtn.style.padding = "8px 18px";
  628. saveBtn.style.fontSize = "15px";
  629. saveBtn.style.cursor = "pointer";
  630. saveBtn.style.flex = "1";
  631. saveBtn.addEventListener("click", async function () {
  632. for (const key of Object.keys(tempSettings)) {
  633. await setSetting(key, tempSettings[key]);
  634. }
  635. saveBtn.textContent = "Saved!";
  636. setTimeout(() => {
  637. saveBtn.textContent = "Save";
  638. }, 900);
  639. setTimeout(() => {
  640. window.location.reload();
  641. }, 400);
  642. });
  643. buttonContainer.appendChild(saveBtn);
  644. const resetBtn = document.createElement("button");
  645. resetBtn.textContent = "Reset";
  646. resetBtn.style.background = "#dd3333";
  647. resetBtn.style.color = "#fff";
  648. resetBtn.style.border = "none";
  649. resetBtn.style.borderRadius = "4px";
  650. resetBtn.style.padding = "8px 18px";
  651. resetBtn.style.fontSize = "15px";
  652. resetBtn.style.cursor = "pointer";
  653. resetBtn.style.flex = "1";
  654. resetBtn.addEventListener("click", async function () {
  655. if (confirm("Reset all 8chanSS settings to defaults?")) {
  656. const keys = await GM.listValues();
  657. for (const key of keys) {
  658. if (key.startsWith("8chanSS_")) {
  659. await GM.deleteValue(key);
  660. }
  661. }
  662. resetBtn.textContent = "Reset!";
  663. setTimeout(() => {
  664. resetBtn.textContent = "Reset";
  665. }, 900);
  666. setTimeout(() => {
  667. window.location.reload();
  668. }, 400);
  669. }
  670. });
  671. buttonContainer.appendChild(resetBtn);
  672.  
  673. menu.appendChild(buttonContainer);
  674. const info = document.createElement("div");
  675. info.style.fontSize = "11px";
  676. info.style.padding = "0 18px 12px";
  677. info.style.opacity = "0.7";
  678. info.style.textAlign = "center";
  679. info.innerHTML = 'Press Save to apply changes. Page will reload. - <a href="https://github.com/otacoo/8chanSS/blob/main/CHANGELOG.md" target="_blank" title="Check the changelog." style="color: #fff; text-decoration: underline dashed;">Ver. 1.34.0</a>';
  680. menu.appendChild(info);
  681.  
  682. document.body.appendChild(menu);
  683. return menu;
  684. }
  685. function createTabContent(category, tempSettings) {
  686. const container = document.createElement("div");
  687. const categorySettings = scriptSettings[category];
  688.  
  689. Object.keys(categorySettings).forEach((key) => {
  690. const setting = categorySettings[key];
  691. if (setting.type === "separator") {
  692. const hr = document.createElement("hr");
  693. hr.style.border = "none";
  694. hr.style.borderTop = "1px solid #444";
  695. hr.style.margin = "12px 0";
  696. container.appendChild(hr);
  697. return;
  698. }
  699. if (setting.type === "title") {
  700. const title = document.createElement("div");
  701. title.textContent = setting.label;
  702. title.style.fontWeight = "bold";
  703. title.style.fontSize = "1rem";
  704. title.style.margin = "10px 0 6px 0";
  705. title.style.opacity = "0.9";
  706. container.appendChild(title);
  707. return;
  708. }
  709. const parentRow = document.createElement("div");
  710. parentRow.style.display = "flex";
  711. parentRow.style.alignItems = "center";
  712. parentRow.style.marginBottom = "0px";
  713. if (key === "hoverVideoVolume" && setting.type === "number") {
  714. const label = document.createElement("label");
  715. label.htmlFor = "setting_" + key;
  716. label.textContent = setting.label + ": ";
  717. label.style.flex = "1";
  718.  
  719. const sliderContainer = document.createElement("div");
  720. sliderContainer.style.display = "flex";
  721. sliderContainer.style.alignItems = "center";
  722. sliderContainer.style.flex = "1";
  723.  
  724. const slider = document.createElement("input");
  725. slider.type = "range";
  726. slider.id = "setting_" + key;
  727. slider.min = setting.min;
  728. slider.max = setting.max;
  729. slider.value = Number(tempSettings[key]).toString();
  730. slider.style.flex = "unset";
  731. slider.style.width = "100px";
  732. slider.style.marginRight = "10px";
  733.  
  734. const valueLabel = document.createElement("span");
  735. valueLabel.textContent = slider.value + "%";
  736. valueLabel.style.minWidth = "40px";
  737. valueLabel.style.textAlign = "right";
  738.  
  739. slider.addEventListener("input", function () {
  740. let val = Number(slider.value);
  741. if (isNaN(val)) val = setting.default;
  742. val = Math.max(setting.min, Math.min(setting.max, val));
  743. slider.value = val.toString();
  744. tempSettings[key] = val;
  745. valueLabel.textContent = val + "%";
  746. });
  747.  
  748. sliderContainer.appendChild(slider);
  749. sliderContainer.appendChild(valueLabel);
  750.  
  751. parentRow.appendChild(label);
  752. parentRow.appendChild(sliderContainer);
  753. const wrapper = document.createElement("div");
  754. wrapper.style.marginBottom = "10px";
  755. wrapper.appendChild(parentRow);
  756. container.appendChild(wrapper);
  757. return;
  758. }
  759. const checkbox = document.createElement("input");
  760. checkbox.type = "checkbox";
  761. checkbox.id = "setting_" + key;
  762. checkbox.checked =
  763. tempSettings[key] === true || tempSettings[key] === "true";
  764. checkbox.style.marginRight = "8px";
  765. const label = document.createElement("label");
  766. label.htmlFor = checkbox.id;
  767. label.textContent = setting.label;
  768. label.style.flex = "1";
  769. let chevron = null;
  770. let subOptionsContainer = null;
  771. if (setting?.subOptions) {
  772. chevron = document.createElement("span");
  773. chevron.className = "ss-chevron";
  774. chevron.innerHTML = "&#9654;";
  775. chevron.style.display = "inline-block";
  776. chevron.style.transition = "transform 0.2s";
  777. chevron.style.marginLeft = "6px";
  778. chevron.style.fontSize = "12px";
  779. chevron.style.userSelect = "none";
  780. chevron.style.transform = checkbox.checked
  781. ? "rotate(90deg)"
  782. : "rotate(0deg)";
  783. }
  784. checkbox.addEventListener("change", function () {
  785. tempSettings[key] = checkbox.checked;
  786. if (!setting?.subOptions) return;
  787. if (!subOptionsContainer) return;
  788.  
  789. subOptionsContainer.style.display = checkbox.checked
  790. ? "block"
  791. : "none";
  792.  
  793. if (!chevron) return;
  794. chevron.style.transform = checkbox.checked
  795. ? "rotate(90deg)"
  796. : "rotate(0deg)";
  797. });
  798.  
  799. parentRow.appendChild(checkbox);
  800. parentRow.appendChild(label);
  801. if (chevron) parentRow.appendChild(chevron);
  802. const wrapper = document.createElement("div");
  803. wrapper.style.marginBottom = "10px";
  804.  
  805. wrapper.appendChild(parentRow);
  806. if (setting?.subOptions) {
  807. subOptionsContainer = document.createElement("div");
  808. subOptionsContainer.style.marginLeft = "25px";
  809. subOptionsContainer.style.marginTop = "5px";
  810. subOptionsContainer.style.display = checkbox.checked ? "block" : "none";
  811.  
  812. Object.keys(setting.subOptions).forEach((subKey) => {
  813. const subSetting = setting.subOptions[subKey];
  814. const fullKey = `${key}_${subKey}`;
  815.  
  816. const subWrapper = document.createElement("div");
  817. subWrapper.style.marginBottom = "5px";
  818.  
  819. if (subSetting.type === "text") {
  820. const subLabel = document.createElement("label");
  821. subLabel.htmlFor = "setting_" + fullKey;
  822. subLabel.textContent = subSetting.label + ": ";
  823.  
  824. const subInput = document.createElement("input");
  825. subInput.type = "text";
  826. subInput.id = "setting_" + fullKey;
  827. subInput.value = tempSettings[fullKey] || "";
  828. subInput.maxLength = subSetting.maxLength;
  829. subInput.style.width = "60px";
  830. subInput.style.marginLeft = "2px";
  831. subInput.placeholder = "(!) ";
  832. subInput.addEventListener("input", function () {
  833. let val = subInput.value.replace(/[<>"']/g, "");
  834. if (val.length > subInput.maxLength) {
  835. val = val.slice(0, subInput.maxLength);
  836. }
  837. subInput.value = val;
  838. tempSettings[fullKey] = val;
  839. });
  840.  
  841. subWrapper.appendChild(subLabel);
  842. subWrapper.appendChild(subInput);
  843. } else {
  844. const subCheckbox = document.createElement("input");
  845. subCheckbox.type = "checkbox";
  846. subCheckbox.id = "setting_" + fullKey;
  847. subCheckbox.checked = tempSettings[fullKey];
  848. subCheckbox.style.marginRight = "8px";
  849.  
  850. subCheckbox.addEventListener("change", function () {
  851. tempSettings[fullKey] = subCheckbox.checked;
  852. });
  853.  
  854. const subLabel = document.createElement("label");
  855. subLabel.htmlFor = subCheckbox.id;
  856. subLabel.textContent = subSetting.label;
  857.  
  858. subWrapper.appendChild(subCheckbox);
  859. subWrapper.appendChild(subLabel);
  860. }
  861. subOptionsContainer.appendChild(subWrapper);
  862. });
  863.  
  864. wrapper.appendChild(subOptionsContainer);
  865. }
  866.  
  867. container.appendChild(wrapper);
  868. });
  869.  
  870. return container;
  871. }
  872. if (link) {
  873. let menu = await createSettingsMenu();
  874. link.style.cursor = "pointer";
  875. link.title = "Open 8chanSS settings";
  876. link.addEventListener("click", async function (e) {
  877. e.preventDefault();
  878. let menu = await createSettingsMenu();
  879. menu.style.display = menu.style.display === "none" ? "block" : "none";
  880. });
  881. }
  882. async function featureSaveScroll() {
  883. const MAX_PAGES = 50;
  884. const currentPage = window.location.origin + window.location.pathname + window.location.search;
  885. const hasAnchor = !!window.location.hash;
  886. const threadPagePattern = /^\/[^/]+\/res\/[^/]+\.html$/i;
  887. function isThreadPage(urlPath) {
  888. return threadPagePattern.test(urlPath);
  889. }
  890.  
  891. async function getSavedScrollData() {
  892. const savedData = await GM.getValue(
  893. `8chanSS_scrollPosition_${currentPage}`,
  894. null
  895. );
  896. if (!savedData) return null;
  897. try {
  898. return JSON.parse(savedData);
  899. } catch (e) {
  900. return null;
  901. }
  902. }
  903.  
  904. async function saveScrollPosition() {
  905. if (!isThreadPage(window.location.pathname)) return;
  906. if (!(await getSetting("enableScrollSave"))) return;
  907.  
  908. const scrollPosition = window.scrollY;
  909. const timestamp = Date.now();
  910. const savedData = await getSavedScrollData();
  911. if (savedData && typeof savedData.position === "number") {
  912. if (scrollPosition <= savedData.position) {
  913. return;
  914. }
  915. }
  916. await GM.setValue(
  917. `8chanSS_scrollPosition_${currentPage}`,
  918. JSON.stringify({
  919. position: scrollPosition,
  920. timestamp: timestamp,
  921. })
  922. );
  923.  
  924. await manageScrollStorage();
  925. }
  926.  
  927. async function manageScrollStorage() {
  928. const allKeys = await GM.listValues();
  929. const scrollKeys = allKeys.filter((key) =>
  930. key.startsWith("8chanSS_scrollPosition_")
  931. );
  932.  
  933. if (scrollKeys.length > MAX_PAGES) {
  934. const keyData = await Promise.all(
  935. scrollKeys.map(async (key) => {
  936. let data;
  937. try {
  938. const savedValue = await GM.getValue(key, null);
  939. data = savedValue ? JSON.parse(savedValue) : { position: 0, timestamp: 0 };
  940. } catch (e) {
  941. data = { position: 0, timestamp: 0 };
  942. }
  943. return {
  944. key: key,
  945. timestamp: data.timestamp || 0,
  946. };
  947. })
  948. );
  949. keyData.sort((a, b) => a.timestamp - b.timestamp);
  950. const keysToRemove = keyData.slice(0, keyData.length - MAX_PAGES);
  951. for (const item of keysToRemove) {
  952. await GM.deleteValue(item.key);
  953. }
  954. }
  955. }
  956. async function restoreScrollPosition() {
  957. if (!isThreadPage(window.location.pathname)) return;
  958. if (!(await getSetting("enableScrollSave"))) return;
  959.  
  960. const savedData = await getSavedScrollData();
  961. if (!savedData || typeof savedData.position !== "number") return;
  962. const position = savedData.position;
  963. await GM.setValue(
  964. `8chanSS_scrollPosition_${currentPage}`,
  965. JSON.stringify({
  966. position: position,
  967. timestamp: Date.now(),
  968. })
  969. );
  970.  
  971. if (hasAnchor) {
  972. setTimeout(() => addUnreadLineAtViewportCenter(position), 100);
  973. return;
  974. }
  975. if (!isNaN(position)) {
  976. window.scrollTo(0, position);
  977. setTimeout(() => addUnreadLineAtViewportCenter(position), 100);
  978. }
  979. }
  980. async function addUnreadLineAtViewportCenter(scrollPosition) {
  981. if (!(await getSetting("enableScrollSave_showUnreadLine"))) {
  982. return;
  983. }
  984.  
  985. const divPosts = document.querySelector(".divPosts");
  986. if (!divPosts) return;
  987. const centerX = window.innerWidth / 2;
  988. const centerY = (typeof scrollPosition === "number")
  989. ? (window.innerHeight / 2) + (scrollPosition - window.scrollY)
  990. : window.innerHeight / 2;
  991. let el = document.elementFromPoint(centerX, centerY);
  992. while (el && el !== divPosts && (!el.classList || !el.classList.contains("postCell"))) {
  993. el = el.parentElement;
  994. }
  995. if (!el || el === divPosts || !el.id) return;
  996. if (el.parentElement !== divPosts) return;
  997. const oldMarker = document.getElementById("unread-line");
  998. if (oldMarker && oldMarker.parentNode) {
  999. oldMarker.parentNode.removeChild(oldMarker);
  1000. }
  1001. const marker = document.createElement("hr");
  1002. marker.id = "unread-line";
  1003. if (el.nextSibling) {
  1004. divPosts.insertBefore(marker, el.nextSibling);
  1005. } else {
  1006. divPosts.appendChild(marker);
  1007. }
  1008. }
  1009. window.addEventListener("beforeunload", () => {
  1010. saveScrollPosition();
  1011. });
  1012. window.addEventListener("load", async () => {
  1013. await restoreScrollPosition();
  1014. });
  1015. await restoreScrollPosition();
  1016. }
  1017. async function removeUnreadLineIfAtBottom() {
  1018. if (!(await getSetting("enableScrollSave_showUnreadLine"))) {
  1019. return;
  1020. }
  1021. const margin = 20;
  1022. if ((window.innerHeight + window.scrollY) >= (document.body.offsetHeight - margin)) {
  1023. const oldMarker = document.getElementById("unread-line");
  1024. if (oldMarker && oldMarker.parentNode) {
  1025. oldMarker.parentNode.removeChild(oldMarker);
  1026. }
  1027. }
  1028. }
  1029.  
  1030. window.addEventListener("scroll", removeUnreadLineIfAtBottom);
  1031. async function featureHeaderCatalogLinks() {
  1032. async function appendCatalogToLinks() {
  1033. const navboardsSpan = document.getElementById("navBoardsSpan");
  1034. if (navboardsSpan) {
  1035. const links = navboardsSpan.getElementsByTagName("a");
  1036. const openInNewTab = await getSetting(
  1037. "enableHeaderCatalogLinks_openInNewTab"
  1038. );
  1039.  
  1040. for (let link of links) {
  1041. if (link.href && !link.href.endsWith("/catalog.html")) {
  1042. link.href += "/catalog.html";
  1043. if (openInNewTab) {
  1044. link.target = "_blank";
  1045. link.rel = "noopener noreferrer";
  1046. } else {
  1047. link.target = "";
  1048. link.rel = "";
  1049. }
  1050. }
  1051. }
  1052. }
  1053. }
  1054.  
  1055. appendCatalogToLinks();
  1056. const observer = new MutationObserver(appendCatalogToLinks);
  1057. const config = { childList: true, subtree: true };
  1058. const navboardsSpan = document.getElementById("navBoardsSpan");
  1059. if (navboardsSpan) {
  1060. observer.observe(navboardsSpan, config);
  1061. }
  1062. }
  1063. function featureImageHover() {
  1064. const MEDIA_MAX_WIDTH = "90vw";
  1065. const MEDIA_OPACITY_LOADING = "0.75";
  1066. const MEDIA_OPACITY_LOADED = "1";
  1067. const MEDIA_OFFSET = 2;
  1068. const MEDIA_BOTTOM_MARGIN = 3;
  1069. const AUDIO_INDICATOR_TEXT = "▶ Playing audio...";
  1070. function getMediaOffset() {
  1071. return window.innerWidth * (MEDIA_OFFSET / 100);
  1072. }
  1073. function getMediaBottomMargin() {
  1074. return window.innerHeight * (MEDIA_BOTTOM_MARGIN / 100);
  1075. }
  1076. let floatingMedia = null;
  1077. let cleanupFns = [];
  1078. let currentAudioIndicator = null;
  1079. let lastMouseEvent = null;
  1080. function clamp(val, min, max) {
  1081. return Math.max(min, Math.min(max, val));
  1082. }
  1083. function positionFloatingMedia(event) {
  1084. if (!floatingMedia) return;
  1085. const vw = window.innerWidth;
  1086. const vh = window.innerHeight;
  1087. const mw = floatingMedia.offsetWidth || 0;
  1088. const mh = floatingMedia.offsetHeight || 0;
  1089.  
  1090. const MEDIA_OFFSET_PX = getMediaOffset();
  1091. const MEDIA_BOTTOM_MARGIN_PX = getMediaBottomMargin();
  1092. const SCROLLBAR_WIDTH = window.innerWidth - document.documentElement.clientWidth;
  1093. let x = event.clientX + MEDIA_OFFSET_PX;
  1094. x = clamp(x, 0, vw - mw - SCROLLBAR_WIDTH);
  1095. let y = event.clientY;
  1096. const maxY = vh - mh - MEDIA_BOTTOM_MARGIN_PX;
  1097. y = Math.max(0, Math.min(y, maxY));
  1098.  
  1099. floatingMedia.style.left = `${x}px`;
  1100. floatingMedia.style.top = `${y}px`;
  1101. }
  1102. function positionFloatingMediaInitial(event) {
  1103. if (!floatingMedia) return;
  1104. const vw = window.innerWidth;
  1105. const vh = window.innerHeight;
  1106. const mw = floatingMedia.offsetWidth || 320;
  1107. const mh = floatingMedia.offsetHeight || 240;
  1108.  
  1109. const MEDIA_OFFSET_PX = getMediaOffset();
  1110. const MEDIA_BOTTOM_MARGIN_PX = getMediaBottomMargin();
  1111. const SCROLLBAR_WIDTH = window.innerWidth - document.documentElement.clientWidth;
  1112. let x = vw / 2, y = vh / 2;
  1113. if (event && typeof event.clientX === "number" && typeof event.clientY === "number") {
  1114. x = event.clientX + MEDIA_OFFSET_PX;
  1115. x = clamp(x, 0, vw - mw - SCROLLBAR_WIDTH);
  1116.  
  1117. y = event.clientY;
  1118. const maxY = vh - mh - MEDIA_BOTTOM_MARGIN_PX;
  1119. y = Math.max(0, Math.min(y, maxY));
  1120. } else {
  1121. x = clamp((vw - mw) / 2, 0, vw - mw - SCROLLBAR_WIDTH);
  1122. const maxY = vh - mh - MEDIA_BOTTOM_MARGIN_PX;
  1123. y = clamp((vh - mh - MEDIA_BOTTOM_MARGIN_PX) / 2, 0, maxY);
  1124. }
  1125. floatingMedia.style.left = `${x}px`;
  1126. floatingMedia.style.top = `${y}px`;
  1127. }
  1128. function cleanupFloatingMedia() {
  1129. cleanupFns.forEach(fn => { try { fn(); } catch { } });
  1130. cleanupFns = [];
  1131. if (floatingMedia) {
  1132. if (["VIDEO", "AUDIO"].includes(floatingMedia.tagName)) {
  1133. try {
  1134. floatingMedia.pause();
  1135. floatingMedia.removeAttribute("src");
  1136. floatingMedia.load();
  1137. } catch { }
  1138. }
  1139. floatingMedia.remove();
  1140. floatingMedia = null;
  1141. }
  1142. if (currentAudioIndicator && currentAudioIndicator.parentNode) {
  1143. currentAudioIndicator.parentNode.removeChild(currentAudioIndicator);
  1144. currentAudioIndicator = null;
  1145. }
  1146. }
  1147. function getFullMediaSrc(thumbNode, filemime) {
  1148. if (!thumbNode || !filemime) return null;
  1149. const thumbnailSrc = thumbNode.getAttribute("src");
  1150. if (/\/t_/.test(thumbnailSrc)) {
  1151. let base = thumbnailSrc.replace(/\/t_/, "/");
  1152. base = base.replace(/\.(jpe?g|png|gif|webp|webm|mp4|ogg|mp3|m4a|wav)$/i, "");
  1153. const mimeToExt = {
  1154. "image/jpeg": ".jpg",
  1155. "image/jpg": ".jpg",
  1156. "image/png": ".png",
  1157. "image/gif": ".gif",
  1158. "image/webp": ".webp",
  1159. "image/bmp": ".bmp",
  1160. "video/mp4": ".mp4",
  1161. "video/webm": ".webm",
  1162. "audio/ogg": ".ogg",
  1163. "audio/mpeg": ".mp3",
  1164. "audio/x-m4a": ".m4a",
  1165. "audio/x-wav": ".wav",
  1166. };
  1167. const ext = mimeToExt[filemime.toLowerCase()];
  1168. if (!ext) return null;
  1169. return base + ext;
  1170. }
  1171. if (
  1172. /\/spoiler\.png$/i.test(thumbnailSrc) ||
  1173. /\/custom\.spoiler$/i.test(thumbnailSrc) ||
  1174. /\/audioGenericThumb\.png$/i.test(thumbnailSrc)
  1175. ) {
  1176. const parentA = thumbNode.closest("a.linkThumb, a.imgLink");
  1177. if (parentA && parentA.getAttribute("href")) {
  1178. return parentA.getAttribute("href");
  1179. }
  1180. return null;
  1181. }
  1182. return null;
  1183. }
  1184. async function onThumbEnter(e) {
  1185. cleanupFloatingMedia();
  1186. lastMouseEvent = e;
  1187. const thumb = e.currentTarget;
  1188. let filemime = null, fullSrc = null, isVideo = false, isAudio = false;
  1189. if (thumb.tagName === "IMG") {
  1190. const parentA = thumb.closest("a.linkThumb, a.imgLink");
  1191. if (!parentA) return;
  1192. const href = parentA.getAttribute("href");
  1193. if (!href) return;
  1194. const ext = href.split(".").pop().toLowerCase();
  1195. filemime =
  1196. parentA.getAttribute("data-filemime") ||
  1197. {
  1198. jpg: "image/jpeg",
  1199. jpeg: "image/jpeg",
  1200. png: "image/png",
  1201. gif: "image/gif",
  1202. webp: "image/webp",
  1203. bmp: "image/bmp",
  1204. mp4: "video/mp4",
  1205. webm: "video/webm",
  1206. ogg: "audio/ogg",
  1207. mp3: "audio/mpeg",
  1208. m4a: "audio/x-m4a",
  1209. wav: "audio/wav",
  1210. }[ext];
  1211. fullSrc = getFullMediaSrc(thumb, filemime);
  1212. isVideo = filemime && filemime.startsWith("video/");
  1213. isAudio = filemime && filemime.startsWith("audio/");
  1214. } else if (thumb.classList.contains("originalNameLink")) {
  1215. const href = thumb.getAttribute("href");
  1216. if (!href) return;
  1217. const ext = href.split(".").pop().toLowerCase();
  1218. if (["mp3", "ogg", "m4a", "wav"].includes(ext)) {
  1219. filemime = {
  1220. ogg: "audio/ogg",
  1221. mp3: "audio/mpeg",
  1222. m4a: "audio/x-m4a",
  1223. wav: "audio/wav",
  1224. }[ext];
  1225. fullSrc = href;
  1226. isAudio = true;
  1227. }
  1228. }
  1229.  
  1230. if (!fullSrc || !filemime) return;
  1231. if (isAudio) {
  1232. const container = thumb.tagName === "IMG"
  1233. ? thumb.closest("a.linkThumb, a.imgLink")
  1234. : thumb;
  1235. if (container && !container.style.position) {
  1236. container.style.position = "relative";
  1237. }
  1238. floatingMedia = document.createElement("audio");
  1239. floatingMedia.src = fullSrc;
  1240. floatingMedia.controls = false;
  1241. floatingMedia.style.display = "none";
  1242. let volume = 0.5;
  1243. try {
  1244. if (typeof getSetting === "function") {
  1245. const v = await getSetting("hoverVideoVolume");
  1246. if (typeof v === "number" && !isNaN(v)) {
  1247. volume = v / 100;
  1248. }
  1249. }
  1250. } catch { }
  1251. floatingMedia.volume = clamp(volume, 0, 1);
  1252. document.body.appendChild(floatingMedia);
  1253. floatingMedia.play().catch(() => { });
  1254. const indicator = document.createElement("div");
  1255. indicator.classList.add("audio-preview-indicator");
  1256. indicator.textContent = AUDIO_INDICATOR_TEXT;
  1257. container.appendChild(indicator);
  1258. currentAudioIndicator = indicator;
  1259. const cleanup = () => cleanupFloatingMedia();
  1260. thumb.addEventListener("mouseleave", cleanup, { once: true });
  1261. container.addEventListener("click", cleanup, { once: true });
  1262. window.addEventListener("scroll", cleanup, { once: true });
  1263. cleanupFns.push(() => thumb.removeEventListener("mouseleave", cleanup));
  1264. cleanupFns.push(() => container.removeEventListener("click", cleanup));
  1265. cleanupFns.push(() => window.removeEventListener("scroll", cleanup));
  1266. return;
  1267. }
  1268. floatingMedia = isVideo ? document.createElement("video") : document.createElement("img");
  1269. floatingMedia.src = fullSrc;
  1270. floatingMedia.style.position = "fixed";
  1271. floatingMedia.style.zIndex = "9999";
  1272. floatingMedia.style.pointerEvents = "none";
  1273. floatingMedia.style.opacity = MEDIA_OPACITY_LOADING;
  1274. floatingMedia.style.left = "-9999px";
  1275. floatingMedia.style.top = "-9999px";
  1276. floatingMedia.style.maxWidth = MEDIA_MAX_WIDTH;
  1277. const availableHeight = window.innerHeight - getMediaBottomMargin();
  1278. floatingMedia.style.maxHeight = `${availableHeight}px`;
  1279. if (isVideo) {
  1280. floatingMedia.autoplay = true;
  1281. floatingMedia.loop = true;
  1282. floatingMedia.muted = false;
  1283. floatingMedia.playsInline = true;
  1284. }
  1285. document.body.appendChild(floatingMedia);
  1286. function initialPlacement() {
  1287. if (lastMouseEvent) {
  1288. positionFloatingMedia(lastMouseEvent);
  1289. }
  1290. }
  1291. function enableMouseMove() {
  1292. document.addEventListener("mousemove", mouseMoveHandler);
  1293. cleanupFns.push(() => document.removeEventListener("mousemove", mouseMoveHandler));
  1294. }
  1295. function mouseMoveHandler(ev) {
  1296. positionFloatingMedia(ev);
  1297. }
  1298. if (isVideo) {
  1299. floatingMedia.onloadeddata = function () {
  1300. initialPlacement();
  1301. enableMouseMove();
  1302. if (floatingMedia) floatingMedia.style.opacity = MEDIA_OPACITY_LOADED;
  1303. };
  1304. } else {
  1305. floatingMedia.onload = function () {
  1306. initialPlacement();
  1307. enableMouseMove();
  1308. if (floatingMedia) floatingMedia.style.opacity = MEDIA_OPACITY_LOADED;
  1309. };
  1310. }
  1311. floatingMedia.onerror = cleanupFloatingMedia;
  1312. function leaveHandler() { cleanupFloatingMedia(); }
  1313. thumb.addEventListener("mouseleave", leaveHandler, { once: true });
  1314. window.addEventListener("scroll", leaveHandler, { once: true });
  1315. cleanupFns.push(() => thumb.removeEventListener("mouseleave", leaveHandler));
  1316. cleanupFns.push(() => window.removeEventListener("scroll", leaveHandler));
  1317. }
  1318. function attachThumbListeners(root = document) {
  1319. root.querySelectorAll("a.linkThumb > img, a.imgLink > img").forEach(thumb => {
  1320. if (!thumb._fullImgHoverBound) {
  1321. thumb.addEventListener("mouseenter", onThumbEnter);
  1322. thumb._fullImgHoverBound = true;
  1323. }
  1324. });
  1325. root.querySelectorAll("a.originalNameLink").forEach(link => {
  1326. const href = link.getAttribute("href") || "";
  1327. const ext = href.split(".").pop().toLowerCase();
  1328. if (
  1329. ["mp3", "wav", "ogg", "m4a"].includes(ext) &&
  1330. !link._audioHoverBound
  1331. ) {
  1332. link.addEventListener("mouseenter", onThumbEnter);
  1333. link._audioHoverBound = true;
  1334. }
  1335. });
  1336. }
  1337. attachThumbListeners();
  1338. new MutationObserver(mutations => {
  1339. for (const mutation of mutations) {
  1340. for (const node of mutation.addedNodes) {
  1341. if (node.nodeType === Node.ELEMENT_NODE) {
  1342. attachThumbListeners(node);
  1343. }
  1344. }
  1345. }
  1346. }).observe(document.body, { childList: true, subtree: true });
  1347. }
  1348. function featureNestedReplies() {
  1349. function ensureReplyPreviewPlacement(root = document) {
  1350. root.querySelectorAll('.innerPost').forEach(innerPost => {
  1351. const divMessage = innerPost.querySelector('.divMessage');
  1352. if (!divMessage) return;
  1353. const replyPreview = innerPost.querySelector('.replyPreview');
  1354. if (replyPreview && replyPreview.nextSibling !== divMessage) {
  1355. innerPost.insertBefore(replyPreview, divMessage);
  1356. }
  1357. innerPost.querySelectorAll('.inlineQuote').forEach(inlineQuote => {
  1358. if (inlineQuote.nextSibling !== divMessage) {
  1359. innerPost.insertBefore(inlineQuote, divMessage);
  1360. }
  1361. });
  1362. });
  1363. }
  1364. ensureReplyPreviewPlacement();
  1365. const observer = new MutationObserver(mutations => {
  1366. for (const mutation of mutations) {
  1367. for (const node of mutation.addedNodes) {
  1368. if (node.nodeType !== 1) continue;
  1369. if (node.matches && node.matches('.innerPost')) {
  1370. ensureReplyPreviewPlacement(node);
  1371. } else if (node.querySelectorAll) {
  1372. node.querySelectorAll('.innerPost').forEach(innerPost => {
  1373. ensureReplyPreviewPlacement(innerPost);
  1374. });
  1375. }
  1376. }
  1377. }
  1378. });
  1379. const postsContainer = document.querySelector('.divPosts');
  1380. if (postsContainer) {
  1381. observer.observe(postsContainer, { childList: true, subtree: true });
  1382. }
  1383. document.addEventListener('click', function (e) {
  1384. const a = e.target.closest('.panelBacklinks > a');
  1385. if (!a) return;
  1386. setTimeout(() => {
  1387. a.classList.toggle('reply-inlined');
  1388. }, 0);
  1389. });
  1390. }
  1391. function featureBlurSpoilers() {
  1392. function revealSpoilers() {
  1393. const spoilerLinks = document.querySelectorAll("a.imgLink");
  1394. spoilerLinks.forEach(async (link) => {
  1395. const img = link.querySelector("img");
  1396. if (!img) return;
  1397. const isCustomSpoiler = img.src.includes("/custom.spoiler");
  1398. const isNotThumbnail = !img.src.includes("/.media/t_");
  1399.  
  1400. if (isNotThumbnail || isCustomSpoiler) {
  1401. let href = link.getAttribute("href");
  1402. if (!href) return;
  1403. const match = href.match(/\/\.media\/([^\/]+)\.[a-zA-Z0-9]+$/);
  1404. if (!match) return;
  1405. const transformedSrc = `/.media/t_${match[1]}`;
  1406. img.src = transformedSrc;
  1407. if (await getSetting("blurSpoilers_removeSpoilers")) {
  1408. img.style.filter = "";
  1409. img.style.transition = "";
  1410. img.onmouseover = null;
  1411. img.onmouseout = null;
  1412. return;
  1413. } else {
  1414. img.style.filter = "blur(5px)";
  1415. img.style.transition = "filter 0.3s ease";
  1416. img.addEventListener("mouseover", () => {
  1417. img.style.filter = "none";
  1418. });
  1419. img.addEventListener("mouseout", () => {
  1420. img.style.filter = "blur(5px)";
  1421. });
  1422. }
  1423. }
  1424. });
  1425. }
  1426. revealSpoilers();
  1427. const observer = new MutationObserver(revealSpoilers);
  1428. observer.observe(document.body, { childList: true, subtree: true });
  1429. }
  1430. function decodeHtmlEntitiesTwice(html) {
  1431. const txt = document.createElement('textarea');
  1432. txt.innerHTML = html;
  1433. const once = txt.value;
  1434. txt.innerHTML = once;
  1435. return txt.value;
  1436. }
  1437.  
  1438. function highlightMentions() {
  1439. document.querySelectorAll("#watchedMenu .watchedCell").forEach((cell) => {
  1440. const notification = cell.querySelector(".watchedCellLabel span.watchedNotification");
  1441. const labelLink = cell.querySelector(".watchedCellLabel a");
  1442. const watchedCellLabel = cell.querySelector(".watchedCellLabel");
  1443. if (labelLink) {
  1444. const originalHtml = labelLink.innerHTML;
  1445. const decodedText = decodeHtmlEntitiesTwice(originalHtml);
  1446. if (labelLink.textContent !== decodedText) {
  1447. labelLink.textContent = decodedText;
  1448. }
  1449. }
  1450.  
  1451. if (labelLink) {
  1452. if (!labelLink.dataset.board) {
  1453. const href = labelLink.getAttribute("href");
  1454. const match = href?.match(/^(?:https?:\/\/[^\/]+)?\/([^\/]+)\//);
  1455. if (match) {
  1456. labelLink.dataset.board = `/${match[1]}/ -`;
  1457. }
  1458. if (document.location.href.includes(href)) {
  1459. const watchButton = document.querySelector(".opHead .watchButton");
  1460. if (watchButton) {
  1461. watchButton.style.color = "var(--board-title-color)";
  1462. watchButton.title = "Watched";
  1463. }
  1464. }
  1465. }
  1466. if (notification && notification.textContent.includes("(you)")) {
  1467. labelLink.style.color = "var(--board-title-color)";
  1468. if (watchedCellLabel && !watchedCellLabel.querySelector(".you-mention-label")) {
  1469. const youLabel = document.createElement("span");
  1470. youLabel.className = "you-mention-label";
  1471. youLabel.textContent = " - (You)";
  1472. youLabel.style.color = "var(--board-title-color)";
  1473. watchedCellLabel.appendChild(youLabel);
  1474. }
  1475. } else {
  1476. labelLink.style.color = "";
  1477. const youLabel = watchedCellLabel?.querySelector(".you-mention-label");
  1478. if (youLabel) {
  1479. youLabel.remove();
  1480. }
  1481. }
  1482. }
  1483. });
  1484. }
  1485. highlightMentions();
  1486. const watchedMenu = document.getElementById("watchedMenu");
  1487. if (watchedMenu) {
  1488. const observer = new MutationObserver(() => {
  1489. highlightMentions();
  1490. });
  1491. observer.observe(watchedMenu, { childList: true, subtree: true });
  1492. }
  1493. async function featureWatchThreadOnReply() {
  1494. const getWatchButton = () => document.querySelector(".watchButton");
  1495. function watchThreadIfNotWatched() {
  1496. const btn = getWatchButton();
  1497. if (btn && !btn.classList.contains("watched-active")) {
  1498. btn.click();
  1499. setTimeout(() => {
  1500. btn.classList.add("watched-active");
  1501. }, 100);
  1502. }
  1503. }
  1504. function updateWatchButtonClass() {
  1505. const btn = getWatchButton();
  1506. if (!btn) return;
  1507. if (btn.classList.contains("watched-active")) {
  1508. btn.classList.add("watched-active");
  1509. } else {
  1510. btn.classList.remove("watched-active");
  1511. }
  1512. }
  1513. const submitButton = document.getElementById("qrbutton");
  1514. if (submitButton) {
  1515. submitButton.removeEventListener("click", submitButton._watchThreadHandler || (() => { }));
  1516. submitButton._watchThreadHandler = async function () {
  1517. if (await getSetting("watchThreadOnReply")) {
  1518. setTimeout(watchThreadIfNotWatched, 500);
  1519. }
  1520. };
  1521. submitButton.addEventListener("click", submitButton._watchThreadHandler);
  1522. }
  1523. updateWatchButtonClass();
  1524. const btn = getWatchButton();
  1525. if (btn) {
  1526. btn.removeEventListener("click", btn._updateWatchHandler || (() => { }));
  1527. btn._updateWatchHandler = () => setTimeout(updateWatchButtonClass, 100);
  1528. btn.addEventListener("click", btn._updateWatchHandler);
  1529. }
  1530. }
  1531. document.addEventListener("keydown", async function (event) {
  1532. if (
  1533. event.altKey &&
  1534. !event.ctrlKey &&
  1535. !event.shiftKey &&
  1536. !event.metaKey &&
  1537. (event.key === "w" || event.key === "W")
  1538. ) {
  1539. event.preventDefault();
  1540. if (
  1541. typeof getSetting === "function" &&
  1542. (await getSetting("watchThreadOnReply"))
  1543. ) {
  1544. const btn = document.querySelector(".watchButton");
  1545. if (btn && !btn.classList.contains("watched-active")) {
  1546. btn.click();
  1547. setTimeout(() => {
  1548. btn.classList.add("watched-active");
  1549. }, 100);
  1550. }
  1551. }
  1552. }
  1553. });
  1554. async function featureAlwaysShowTW() {
  1555. if (!(await getSetting("alwaysShowTW"))) return;
  1556.  
  1557. function showThreadWatcher() {
  1558. const watchedMenu = document.getElementById("watchedMenu");
  1559. if (watchedMenu) {
  1560. watchedMenu.style.display = "flex";
  1561. }
  1562. }
  1563.  
  1564. function addCloseListener() {
  1565. const watchedMenu = document.getElementById("watchedMenu");
  1566. if (!watchedMenu) return;
  1567. const closeBtn = watchedMenu.querySelector(".close-btn");
  1568. if (closeBtn) {
  1569. closeBtn.addEventListener("click", () => {
  1570. watchedMenu.style.display = "none";
  1571. });
  1572. }
  1573. }
  1574. onReady(() => {
  1575. showThreadWatcher();
  1576. addCloseListener();
  1577. });
  1578. }
  1579. function featureMarkYourPost() {
  1580. function getBoardName() {
  1581. const postCell = document.querySelector('.postCell[data-boarduri], .opCell[data-boarduri]');
  1582. if (postCell) return postCell.getAttribute('data-boarduri');
  1583. const match = location.pathname.match(/^\/([^\/]+)\//);
  1584. return match ? match[1] : 'unknown';
  1585. }
  1586.  
  1587. const BOARD_NAME = getBoardName();
  1588. const T_YOUS_KEY = `${BOARD_NAME}-yous`;
  1589. const MENU_ENTRY_CLASS = "markYourPostMenuEntry";
  1590. const MENU_SELECTOR = ".floatingList.extraMenu";
  1591. function getTYous() {
  1592. try {
  1593. const val = localStorage.getItem(T_YOUS_KEY);
  1594. if (!val) return [];
  1595. return JSON.parse(val);
  1596. } catch {
  1597. return [];
  1598. }
  1599. }
  1600. function setTYous(arr) {
  1601. localStorage.setItem(T_YOUS_KEY, JSON.stringify(arr.map(Number)));
  1602. }
  1603. document.body.addEventListener('click', function (e) {
  1604. if (e.target.matches('.extraMenuButton')) {
  1605. const postCell = e.target.closest('.postCell, .opCell');
  1606. setTimeout(() => {
  1607. const menu = document.querySelector(MENU_SELECTOR);
  1608. if (menu && postCell) {
  1609. menu.setAttribute('data-post-id', postCell.id);
  1610. }
  1611. }, 0);
  1612. }
  1613. });
  1614.  
  1615. function getPostIdFromMenu(menu) {
  1616. return menu.getAttribute('data-post-id') || null;
  1617. }
  1618.  
  1619. function toggleYouNameClass(postId, add) {
  1620. const postCell = document.getElementById(postId);
  1621. if (!postCell) return;
  1622. const nameLink = postCell.querySelector(".linkName.noEmailName");
  1623. if (nameLink) {
  1624. nameLink.classList.toggle("youName", add);
  1625. }
  1626. }
  1627. function addMenuEntries(root = document) {
  1628. root.querySelectorAll(MENU_SELECTOR).forEach(menu => {
  1629. const ul = menu.querySelector("ul");
  1630. if (!ul || ul.querySelector("." + MENU_ENTRY_CLASS)) return;
  1631.  
  1632. const reportLi = Array.from(ul.children).find(
  1633. li => li.textContent.trim().toLowerCase() === "report"
  1634. );
  1635.  
  1636. const li = document.createElement("li");
  1637. li.className = MENU_ENTRY_CLASS;
  1638. li.style.cursor = "pointer";
  1639.  
  1640. const postId = getPostIdFromMenu(menu);
  1641. const tYous = getTYous();
  1642. const isMarked = postId && tYous.includes(Number(postId));
  1643. li.textContent = isMarked ? "Unmark as Your Post" : "Mark as Your Post";
  1644.  
  1645. if (reportLi) {
  1646. ul.insertBefore(li, reportLi);
  1647. } else {
  1648. ul.insertBefore(li, ul.firstChild);
  1649. }
  1650.  
  1651. li.addEventListener("click", function (e) {
  1652. e.stopPropagation();
  1653. const postId = getPostIdFromMenu(menu);
  1654. if (!postId) return;
  1655. let tYous = getTYous();
  1656. const numericPostId = Number(postId);
  1657. const idx = tYous.indexOf(numericPostId);
  1658. if (idx === -1) {
  1659. tYous.push(numericPostId);
  1660. setTYous(tYous);
  1661. toggleYouNameClass(postId, true);
  1662. li.textContent = "Unmark as Your Post";
  1663. } else {
  1664. tYous.splice(idx, 1);
  1665. setTYous(tYous);
  1666. toggleYouNameClass(postId, false);
  1667. li.textContent = "Mark as Your Post";
  1668. }
  1669. });
  1670.  
  1671. window.addEventListener("storage", function (event) {
  1672. if (event.key === T_YOUS_KEY) {
  1673. const tYous = getTYous();
  1674. const isMarked = postId && tYous.includes(Number(postId));
  1675. li.textContent = isMarked ? "Unmark as Your Post" : "Mark as Your Post";
  1676. }
  1677. });
  1678. });
  1679. }
  1680. const observer = new MutationObserver(mutations => {
  1681. for (const mutation of mutations) {
  1682. for (const node of mutation.addedNodes) {
  1683. if (node.nodeType !== 1) continue;
  1684. if (node.matches && node.matches(MENU_SELECTOR)) {
  1685. addMenuEntries(node.parentNode || node);
  1686. } else if (node.querySelectorAll) {
  1687. node.querySelectorAll(MENU_SELECTOR).forEach(menu => {
  1688. addMenuEntries(menu.parentNode || menu);
  1689. });
  1690. }
  1691. }
  1692. }
  1693. });
  1694. observer.observe(document.body, { childList: true, subtree: true });
  1695. }
  1696. onReady(featureMarkYourPost);
  1697. function featureScrollArrows() {
  1698. if (
  1699. document.getElementById("scroll-arrow-up") ||
  1700. document.getElementById("scroll-arrow-down")
  1701. )
  1702. return;
  1703. const upBtn = document.createElement("button");
  1704. upBtn.id = "scroll-arrow-up";
  1705. upBtn.className = "scroll-arrow-btn";
  1706. upBtn.title = "Scroll to top";
  1707. upBtn.innerHTML = "▲";
  1708. upBtn.addEventListener("click", () => {
  1709. window.scrollTo({ top: 0, behavior: "smooth" });
  1710. });
  1711. const downBtn = document.createElement("button");
  1712. downBtn.id = "scroll-arrow-down";
  1713. downBtn.className = "scroll-arrow-btn";
  1714. downBtn.title = "Scroll to bottom";
  1715. downBtn.innerHTML = "▼";
  1716. downBtn.addEventListener("click", () => {
  1717. const footer = document.getElementById("footer");
  1718. if (footer) {
  1719. footer.scrollIntoView({ behavior: "smooth", block: "end" });
  1720. } else {
  1721. window.scrollTo({
  1722. top: document.body.scrollHeight,
  1723. behavior: "smooth",
  1724. });
  1725. }
  1726. });
  1727.  
  1728. document.body.appendChild(upBtn);
  1729. document.body.appendChild(downBtn);
  1730. }
  1731. function featureDeleteNameCheckbox() {
  1732. const nameExists = document.getElementById("qr-name-row");
  1733. if (nameExists && nameExists.classList.contains("hidden")) {
  1734. return;
  1735. }
  1736.  
  1737. const checkbox = document.createElement("input");
  1738. checkbox.type = "checkbox";
  1739. checkbox.id = "saveNameCheckbox";
  1740. checkbox.classList.add("postingCheckbox");
  1741. const label = document.createElement("label");
  1742. label.htmlFor = "saveNameCheckbox";
  1743. label.textContent = "Delete Name";
  1744. label.title = "Delete Name on refresh";
  1745. const alwaysUseBypassCheckbox = document.getElementById("qralwaysUseBypassCheckBox");
  1746. if (!alwaysUseBypassCheckbox) {
  1747. return;
  1748. }
  1749.  
  1750. alwaysUseBypassCheckbox.parentNode.insertBefore(checkbox, alwaysUseBypassCheckbox);
  1751. alwaysUseBypassCheckbox.parentNode.insertBefore(label, checkbox.nextSibling);
  1752. const savedCheckboxState = localStorage.getItem("8chanSS_deleteNameCheckbox") === "true";
  1753. checkbox.checked = savedCheckboxState;
  1754.  
  1755. const nameInput = document.getElementById("qrname");
  1756. if (nameInput) {
  1757. if (checkbox.checked) {
  1758. nameInput.value = "";
  1759. localStorage.removeItem("name");
  1760. }
  1761. checkbox.addEventListener("change", function () {
  1762. localStorage.setItem("8chanSS_deleteNameCheckbox", checkbox.checked);
  1763. });
  1764. }
  1765. }
  1766. function featureBeepOnYou() {
  1767. const beep = new Audio(
  1768. "data:audio/wav;base64,UklGRjQDAABXQVZFZm10IBAAAAABAAEAgD4AAIA+AAABAAgAc21wbDwAAABBAAADAAAAAAAAAAA8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkYXRhzAIAAGMms8em0tleMV4zIpLVo8nhfSlcPR102Ki+5JspVEkdVtKzs+K1NEhUIT7DwKrcy0g6WygsrM2k1NpiLl0zIY/WpMrjgCdbPhxw2Kq+5Z4qUkkdU9K1s+K5NkVTITzBwqnczko3WikrqM+l1NxlLF0zIIvXpsnjgydZPhxs2ay95aIrUEkdUdC3suK8N0NUIjq+xKrcz002WioppdGm091pK1w0IIjYp8jkhydXPxxq2K295aUrTkoeTs65suK+OUFUIzi7xqrb0VA0WSoootKm0t5tKlo1H4TYqMfkiydWQBxm16+85actTEseS8y7seHAPD9TIza5yKra01QyWSson9On0d5wKVk2H4DYqcfkjidUQB1j1rG75KsvSkseScu8seDCPz1TJDW2yara1FYxWSwnm9Sn0N9zKVg2H33ZqsXkkihSQR1g1bK65K0wSEsfR8i+seDEQTxUJTOzy6rY1VowWC0mmNWoz993KVc3H3rYq8TklSlRQh1d1LS647AyR0wgRMbAsN/GRDpTJTKwzKrX1l4vVy4lldWpzt97KVY4IXbUr8LZljVPRCxhw7W3z6ZISkw1VK+4sMWvXEhSPk6buay9sm5JVkZNiLWqtrJ+TldNTnquqbCwilZXU1BwpKirrpNgWFhTaZmnpquZbFlbVmWOpaOonHZcXlljhaGhpZ1+YWBdYn2cn6GdhmdhYGN3lp2enIttY2Jjco+bnJuOdGZlZXCImJqakHpoZ2Zug5WYmZJ/bGlobX6RlpeSg3BqaW16jZSVkoZ0bGtteImSk5KIeG5tbnaFkJKRinxxbm91gY2QkIt/c3BwdH6Kj4+LgnZxcXR8iI2OjIR5c3J0e4WLjYuFe3VzdHmCioyLhn52dHR5gIiKioeAeHV1eH+GiYqHgXp2dnh9hIiJh4J8eHd4fIKHiIeDfXl4eHyBhoeHhH96eHmA"
  1769. );
  1770. window.originalTitle = document.title;
  1771. let isNotifying = false;
  1772. function playBeep() {
  1773. if (beep.paused) {
  1774. beep.play().catch((e) => console.warn("Beep failed:", e));
  1775. } else {
  1776. beep.addEventListener("ended", () => beep.play(), { once: true });
  1777. }
  1778. }
  1779. const observer = new MutationObserver((mutations) => {
  1780. mutations.forEach((mutation) => {
  1781. mutation.addedNodes.forEach(async (node) => {
  1782. if (
  1783. node.nodeType === 1 &&
  1784. node.querySelector &&
  1785. node.querySelector("a.quoteLink.you")
  1786. ) {
  1787. if (node.closest('.innerPost')) {
  1788. return;
  1789. }
  1790. if (await getSetting("beepOnYou")) {
  1791. playBeep();
  1792. }
  1793. if (await getSetting("notifyOnYou")) {
  1794. featureNotifyOnYou();
  1795. }
  1796. }
  1797. });
  1798. });
  1799. });
  1800.  
  1801. observer.observe(document.body, { childList: true, subtree: true });
  1802. async function featureNotifyOnYou() {
  1803. if (!window.isNotifying && !document.hasFocus()) {
  1804. window.isNotifying = true;
  1805. let customMsg = await getSetting("notifyOnYou_customMessage");
  1806. if (!customMsg) customMsg = "(!) ";
  1807. document.title = customMsg + " " + window.originalTitle;
  1808. if (!window.notifyFocusListenerAdded) {
  1809. window.addEventListener("focus", () => {
  1810. if (window.isNotifying) {
  1811. document.title = window.originalTitle;
  1812. window.isNotifying = false;
  1813. }
  1814. });
  1815. window.notifyFocusListenerAdded = true;
  1816. }
  1817. }
  1818. }
  1819. window.addEventListener("focus", () => {
  1820. if (isNotifying) {
  1821. document.title = window.originalTitle;
  1822. isNotifying = false;
  1823. }
  1824. });
  1825. }
  1826. featureBeepOnYou();
  1827. document.addEventListener("keydown", async function (event) {
  1828. if (event.ctrlKey && event.key === "F1") {
  1829. event.preventDefault();
  1830. let menu =
  1831. document.getElementById("8chanSS-menu") ||
  1832. (await createSettingsMenu());
  1833. menu.style.display =
  1834. menu.style.display === "none" || menu.style.display === ""
  1835. ? "block"
  1836. : "none";
  1837. }
  1838. });
  1839. async function submitWithCtrlEnter(event) {
  1840. if (event.ctrlKey && event.key === "Enter") {
  1841. event.preventDefault();
  1842. const submitButton = document.getElementById("qrbutton");
  1843. if (submitButton) {
  1844. submitButton.click();
  1845. if (await getSetting("watchThreadOnReply")) {
  1846. setTimeout(() => {
  1847. const btn = document.querySelector(".watchButton");
  1848. if (btn && !btn.classList.contains("watched-active")) {
  1849. btn.click();
  1850. setTimeout(() => {
  1851. btn.classList.add("watched-active");
  1852. }, 100);
  1853. }
  1854. }, 500);
  1855. }
  1856. }
  1857. }
  1858. }
  1859. const replyTextarea = document.getElementById("qrbody");
  1860. if (replyTextarea) {
  1861. replyTextarea.addEventListener("keydown", submitWithCtrlEnter);
  1862. }
  1863. function toggleQR(event) {
  1864. if (event.ctrlKey && (event.key === "q" || event.key === "Q")) {
  1865. const hiddenDiv = document.getElementById("quick-reply");
  1866. if (
  1867. hiddenDiv.style.display === "none" ||
  1868. hiddenDiv.style.display === ""
  1869. ) {
  1870. hiddenDiv.style.display = "block";
  1871. setTimeout(() => {
  1872. const textarea = document.getElementById("qrbody");
  1873. if (textarea) {
  1874. textarea.focus();
  1875. }
  1876. }, 50);
  1877. } else {
  1878. hiddenDiv.style.display = "none";
  1879. }
  1880. }
  1881. }
  1882. document.addEventListener("keydown", toggleQR);
  1883. function clearTextarea(event) {
  1884. if (event.key === "Escape") {
  1885. const textarea = document.getElementById("qrbody");
  1886. if (textarea) {
  1887. textarea.value = "";
  1888. }
  1889. const quickReply = document.getElementById("quick-reply");
  1890. if (quickReply) {
  1891. quickReply.style.display = "none";
  1892. }
  1893. }
  1894. }
  1895. document.addEventListener("keydown", clearTextarea);
  1896. function featureScrollBetweenPosts() {
  1897. let lastHighlighted = null;
  1898. let lastType = null;
  1899. let lastIndex = -1;
  1900.  
  1901. function getEligiblePostCells(isOwnReply) {
  1902. const selector = isOwnReply
  1903. ? '.postCell:has(a.youName), .opCell:has(a.youName)'
  1904. : '.postCell:has(a.quoteLink.you), .opCell:has(a.quoteLink.you)';
  1905. return Array.from(document.querySelectorAll(selector));
  1906. }
  1907.  
  1908. function scrollToReply(isOwnReply = true, getNextReply = true) {
  1909. const postCells = getEligiblePostCells(isOwnReply);
  1910. if (!postCells.length) return;
  1911. let currentIndex = -1;
  1912. if (
  1913. lastType === (isOwnReply ? "own" : "reply") &&
  1914. lastHighlighted &&
  1915. (currentIndex = postCells.indexOf(lastHighlighted.closest('.postCell, .opCell'))) !== -1
  1916. ) {
  1917. } else {
  1918. const viewportMiddle = window.innerHeight / 2;
  1919. currentIndex = postCells.findIndex(cell => {
  1920. const rect = cell.getBoundingClientRect();
  1921. return rect.top + rect.height / 2 > viewportMiddle;
  1922. });
  1923. if (currentIndex === -1) {
  1924. currentIndex = getNextReply ? -1 : postCells.length;
  1925. }
  1926. }
  1927. const targetIndex = getNextReply ? currentIndex + 1 : currentIndex - 1;
  1928. if (targetIndex < 0 || targetIndex >= postCells.length) return;
  1929.  
  1930. const postContainer = postCells[targetIndex];
  1931. if (postContainer) {
  1932. postContainer.scrollIntoView({ behavior: "smooth", block: "center" });
  1933. if (lastHighlighted) {
  1934. lastHighlighted.classList.remove('target-highlight');
  1935. }
  1936. let anchorId = null;
  1937. let anchorElem = postContainer.querySelector('[id^="p"]');
  1938. if (anchorElem && anchorElem.id) {
  1939. anchorId = anchorElem.id;
  1940. } else if (postContainer.id) {
  1941. anchorId = postContainer.id;
  1942. }
  1943. if (anchorId) {
  1944. if (location.hash !== '#' + anchorId) {
  1945. history.replaceState(null, '', '#' + anchorId);
  1946. }
  1947. }
  1948. const innerPost = postContainer.querySelector('.innerPost');
  1949. if (innerPost) {
  1950. innerPost.classList.add('target-highlight');
  1951. lastHighlighted = innerPost;
  1952. } else {
  1953. lastHighlighted = null;
  1954. }
  1955. lastType = isOwnReply ? "own" : "reply";
  1956. lastIndex = targetIndex;
  1957. }
  1958. }
  1959.  
  1960. function onKeyDown(event) {
  1961. if (
  1962. event.target &&
  1963. (
  1964. /^(input|textarea)$/i.test(event.target.tagName) ||
  1965. event.target.isContentEditable
  1966. )
  1967. ) return;
  1968.  
  1969. if (event.ctrlKey && event.shiftKey) {
  1970. if (event.key === 'ArrowDown') {
  1971. event.preventDefault();
  1972. scrollToReply(false, true);
  1973. } else if (event.key === 'ArrowUp') {
  1974. event.preventDefault();
  1975. scrollToReply(false, false);
  1976. }
  1977. } else if (event.ctrlKey) {
  1978. if (event.key === 'ArrowDown') {
  1979. event.preventDefault();
  1980. scrollToReply(true, true);
  1981. } else if (event.key === 'ArrowUp') {
  1982. event.preventDefault();
  1983. scrollToReply(true, false);
  1984. }
  1985. }
  1986. }
  1987. window.addEventListener('hashchange', () => {
  1988. if (lastHighlighted) {
  1989. lastHighlighted.classList.remove('target-highlight');
  1990. lastHighlighted = null;
  1991. }
  1992. const hash = location.hash.replace('#', '');
  1993. if (hash) {
  1994. const postElem = document.getElementById(hash);
  1995. if (postElem) {
  1996. const innerPost = postElem.querySelector('.innerPost');
  1997. if (innerPost) {
  1998. innerPost.classList.add('target-highlight');
  1999. lastHighlighted = innerPost;
  2000. }
  2001. }
  2002. }
  2003. });
  2004.  
  2005. document.addEventListener('keydown', onKeyDown);
  2006. }
  2007. featureScrollBetweenPosts();
  2008. const bbCodeCombinations = new Map([
  2009. ["s", ["[spoiler]", "[/spoiler]"]],
  2010. ["b", ["'''", "'''"]],
  2011. ["u", ["__", "__"]],
  2012. ["i", ["''", "''"]],
  2013. ["d", ["[doom]", "[/doom]"]],
  2014. ["m", ["[moe]", "[/moe]"]],
  2015. ["c", ["[code]", "[/code]"]],
  2016. ]);
  2017.  
  2018. function replyKeyboardShortcuts(ev) {
  2019. const key = ev.key.toLowerCase();
  2020. if (
  2021. key === "c" &&
  2022. ev.altKey &&
  2023. !ev.ctrlKey &&
  2024. bbCodeCombinations.has(key)
  2025. ) {
  2026. ev.preventDefault();
  2027. const textBox = ev.target;
  2028. const [openTag, closeTag] = bbCodeCombinations.get(key);
  2029. const { selectionStart, selectionEnd, value } = textBox;
  2030. if (selectionStart === selectionEnd) {
  2031. const before = value.slice(0, selectionStart);
  2032. const after = value.slice(selectionEnd);
  2033. const newCursor = selectionStart + openTag.length;
  2034. textBox.value = before + openTag + closeTag + after;
  2035. textBox.selectionStart = textBox.selectionEnd = newCursor;
  2036. } else {
  2037. const before = value.slice(0, selectionStart);
  2038. const selected = value.slice(selectionStart, selectionEnd);
  2039. const after = value.slice(selectionEnd);
  2040. textBox.value = before + openTag + selected + closeTag + after;
  2041. textBox.selectionStart = selectionStart + openTag.length;
  2042. textBox.selectionEnd = selectionEnd + openTag.length;
  2043. }
  2044. return;
  2045. }
  2046. if (
  2047. ev.ctrlKey &&
  2048. !ev.altKey &&
  2049. bbCodeCombinations.has(key) &&
  2050. key !== "c"
  2051. ) {
  2052. ev.preventDefault();
  2053. const textBox = ev.target;
  2054. const [openTag, closeTag] = bbCodeCombinations.get(key);
  2055. const { selectionStart, selectionEnd, value } = textBox;
  2056. if (selectionStart === selectionEnd) {
  2057. const before = value.slice(0, selectionStart);
  2058. const after = value.slice(selectionEnd);
  2059. const newCursor = selectionStart + openTag.length;
  2060. textBox.value = before + openTag + closeTag + after;
  2061. textBox.selectionStart = textBox.selectionEnd = newCursor;
  2062. } else {
  2063. const before = value.slice(0, selectionStart);
  2064. const selected = value.slice(selectionStart, selectionEnd);
  2065. const after = value.slice(selectionEnd);
  2066. textBox.value = before + openTag + selected + closeTag + after;
  2067. textBox.selectionStart = selectionStart + openTag.length;
  2068. textBox.selectionEnd = selectionEnd + openTag.length;
  2069. }
  2070. return;
  2071. }
  2072. }
  2073. document
  2074. .getElementById("qrbody")
  2075. ?.addEventListener("keydown", replyKeyboardShortcuts);
  2076. function featureCatalogThreadHideShortcut() {
  2077. const STORAGE_KEY = "8chanSS_hiddenCatalogThreads";
  2078. let showHiddenMode = false;
  2079. function getBoardAndThreadNumFromCell(cell) {
  2080. const link = cell.querySelector("a.linkThumb[href*='/res/']");
  2081. if (!link) return { board: null, threadNum: null };
  2082. const match = link.getAttribute("href").match(/^\/([^/]+)\/res\/(\d+)\.html/);
  2083. if (!match) return { board: null, threadNum: null };
  2084. return { board: match[1], threadNum: match[2] };
  2085. }
  2086. async function loadHiddenThreadsObj() {
  2087. const raw = await GM.getValue(STORAGE_KEY, "{}");
  2088. try {
  2089. const obj = JSON.parse(raw);
  2090. return typeof obj === "object" && obj !== null ? obj : {};
  2091. } catch {
  2092. return {};
  2093. }
  2094. }
  2095. async function saveHiddenThreadsObj(obj) {
  2096. await GM.setValue(STORAGE_KEY, JSON.stringify(obj));
  2097. }
  2098. async function applyHiddenThreads() {
  2099. const STORAGE_KEY = "8chanSS_hiddenCatalogThreads";
  2100. const hiddenThreadsObjRaw = await GM.getValue(STORAGE_KEY, "{}");
  2101. let hiddenThreadsObj;
  2102. try {
  2103. hiddenThreadsObj = JSON.parse(hiddenThreadsObjRaw);
  2104. if (typeof hiddenThreadsObj !== "object" || hiddenThreadsObj === null) hiddenThreadsObj = {};
  2105. } catch {
  2106. hiddenThreadsObj = {};
  2107. }
  2108. document.querySelectorAll(".catalogCell").forEach(cell => {
  2109. const { board, threadNum } = getBoardAndThreadNumFromCell(cell);
  2110. if (!board || !threadNum) return;
  2111. const hiddenThreads = hiddenThreadsObj[board] || [];
  2112.  
  2113. if (typeof showHiddenMode !== "undefined" && showHiddenMode) {
  2114. if (hiddenThreads.includes(threadNum)) {
  2115. cell.style.display = "";
  2116. cell.classList.add("ss-unhide-thread");
  2117. cell.classList.remove("ss-hidden-thread");
  2118. } else {
  2119. cell.style.display = "none";
  2120. cell.classList.remove("ss-unhide-thread", "ss-hidden-thread");
  2121. }
  2122. } else {
  2123. if (hiddenThreads.includes(threadNum)) {
  2124. cell.style.display = "none";
  2125. cell.classList.add("ss-hidden-thread");
  2126. cell.classList.remove("ss-unhide-thread");
  2127. } else {
  2128. cell.style.display = "";
  2129. cell.classList.remove("ss-hidden-thread", "ss-unhide-thread");
  2130. }
  2131. }
  2132. });
  2133. }
  2134. async function onCatalogCellClick(e) {
  2135. const cell = e.target.closest(".catalogCell");
  2136. if (!cell) return;
  2137. if (e.shiftKey && e.button === 0) {
  2138. const { board, threadNum } = getBoardAndThreadNumFromCell(cell);
  2139. if (!board || !threadNum) return;
  2140.  
  2141. let hiddenThreadsObj = await loadHiddenThreadsObj();
  2142. if (!hiddenThreadsObj[board]) hiddenThreadsObj[board] = [];
  2143. let hiddenThreads = hiddenThreadsObj[board];
  2144.  
  2145. if (showHiddenMode) {
  2146. hiddenThreads = hiddenThreads.filter(num => num !== threadNum);
  2147. hiddenThreadsObj[board] = hiddenThreads;
  2148. await saveHiddenThreadsObj(hiddenThreadsObj);
  2149. await applyHiddenThreads();
  2150. } else {
  2151. if (!hiddenThreads.includes(threadNum)) {
  2152. hiddenThreads.push(threadNum);
  2153. hiddenThreadsObj[board] = hiddenThreads;
  2154. }
  2155. await saveHiddenThreadsObj(hiddenThreadsObj);
  2156. cell.style.display = "none";
  2157. cell.classList.add("ss-hidden-thread");
  2158. }
  2159. e.preventDefault();
  2160. e.stopPropagation();
  2161. }
  2162. }
  2163. async function showAllHiddenThreads() {
  2164. showHiddenMode = true;
  2165. await applyHiddenThreads();
  2166. const btn = document.getElementById("ss-show-hidden-btn");
  2167. if (btn) btn.textContent = "Hide Hidden";
  2168. }
  2169. async function hideAllHiddenThreads() {
  2170. showHiddenMode = false;
  2171. await applyHiddenThreads();
  2172. const btn = document.getElementById("ss-show-hidden-btn");
  2173. if (btn) btn.textContent = "Show Hidden";
  2174. }
  2175. async function toggleShowHiddenThreads() {
  2176. if (showHiddenMode) {
  2177. await hideAllHiddenThreads();
  2178. } else {
  2179. await showAllHiddenThreads();
  2180. }
  2181. }
  2182. function addShowHiddenButton() {
  2183. if (document.getElementById("ss-show-hidden-btn")) return;
  2184. const refreshBtn = document.querySelector("#catalogRefreshButton");
  2185. if (!refreshBtn) return;
  2186. const btn = document.createElement("button");
  2187. btn.id = "ss-show-hidden-btn";
  2188. btn.className = "catalogLabel";
  2189. btn.type = "button";
  2190. btn.textContent = "Show Hidden";
  2191. btn.style.marginRight = "8px";
  2192. btn.addEventListener("click", toggleShowHiddenThreads);
  2193. refreshBtn.parentNode.insertBefore(btn, refreshBtn);
  2194. }
  2195. function hideThreadsOnRefresh() {
  2196. if (!/\/catalog\.html$/.test(window.location.pathname)) return;
  2197. onReady(addShowHiddenButton);
  2198. onReady(applyHiddenThreads);
  2199. const catalogContainer = document.querySelector(".catalogWrapper, .catalogDiv");
  2200. if (catalogContainer) {
  2201. catalogContainer.addEventListener("click", onCatalogCellClick, true);
  2202. const observer = new MutationObserver(applyHiddenThreads);
  2203. observer.observe(catalogContainer, { childList: true, subtree: true });
  2204. }
  2205. }
  2206.  
  2207. hideThreadsOnRefresh();
  2208. }
  2209. const captchaInput = document.getElementById("QRfieldCaptcha");
  2210. if (captchaInput) {
  2211. captchaInput.autocomplete = "off";
  2212. }
  2213. function preventFooterScrollIntoView() {
  2214. const footer = document.getElementById('footer');
  2215. if (footer && !footer._scrollBlocked) {
  2216. footer._scrollBlocked = true;
  2217. footer.scrollIntoView = function () { };
  2218. }
  2219. }
  2220. function moveUploadsBelowOP() {
  2221. const panelUploads = document.querySelector('.panelUploads');
  2222. const opHeadTitle = document.querySelector('.opHead.title');
  2223. if (panelUploads && opHeadTitle) {
  2224. opHeadTitle.appendChild(panelUploads);
  2225. }
  2226. }
  2227. moveUploadsBelowOP();
  2228. });