8chanSS

Userscript to style 8chan

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

  1. // ==UserScript==
  2. // @name 8chanSS
  3. // @version 1.27
  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. swapTheme();
  38. document.addEventListener("DOMContentLoaded", swapTheme);
  39. document.addEventListener("DOMContentLoaded", function () {
  40. const themeSelector = document.getElementById("themeSelector");
  41. if (themeSelector) {
  42. for (let i = 0; i < themeSelector.options.length; i++) {
  43. if (
  44. themeSelector.options[i].value === userTheme ||
  45. themeSelector.options[i].text === userTheme
  46. ) {
  47. themeSelector.selectedIndex = i;
  48. break;
  49. }
  50. }
  51. }
  52. });
  53. })();
  54. (function () {
  55. try {
  56. localStorage.removeItem("hoveringImage");
  57. } catch (e) {
  58. }
  59. })();
  60. function onReady(fn) {
  61. if (document.readyState === "loading") {
  62. document.addEventListener("DOMContentLoaded", fn, { once: true });
  63. } else {
  64. fn();
  65. }
  66. }
  67. onReady(async function () {
  68. const scriptSettings = {
  69. site: {
  70. alwaysShowTW: { label: "Pin Thread Watcher", default: false },
  71. enableHeaderCatalogLinks: {
  72. label: "Header Catalog Links",
  73. default: true,
  74. subOptions: {
  75. openInNewTab: {
  76. label: "Always open in new tab",
  77. default: false,
  78. },
  79. },
  80. },
  81. enableBottomHeader: { label: "Bottom Header", default: false },
  82. enableScrollSave: { label: "Save Scroll Position", default: true },
  83. enableScrollArrows: { label: "Show Up/Down Arrows", default: false, },
  84. hoverVideoVolume: { label: "Hover Media Volume (0-100%)", default: 50, type: "number", min: 0, max: 100, },
  85. },
  86. threads: {
  87. enableThreadImageHover: { label: "Thread Image Hover", default: true, },
  88. watchThreadOnReply: { label: "Watch Thread on Reply", default: true, },
  89. beepOnYou: { label: "Beep on (You)", default: false },
  90. notifyOnYou: { label: "Notify when (You) (!)", default: true },
  91. highlightOnYou: { label: "Highlight (You) posts", default: true },
  92. hideHiddenPostStub: { label: "Hide Stubs of Hidden Posts", default: false, },
  93. blurSpoilers: {
  94. label: "Blur Spoilers",
  95. default: false,
  96. subOptions: {
  97. removeSpoilers: {
  98. label: "Remove Spoilers",
  99. default: false,
  100. },
  101. },
  102. },
  103. deleteSavedName: { label: "Delete Name Checkbox", default: true },
  104. },
  105. catalog: {
  106. enableCatalogImageHover: { label: "Catalog Image Hover", default: true, },
  107. },
  108. styling: {
  109. enableStickyQR: { label: "Enable Sticky Quick Reply", default: false, },
  110. enableFitReplies: { label: "Fit Replies", default: false },
  111. enableSidebar: { label: "Enable Sidebar", default: false },
  112. hideAnnouncement: { label: "Hide Announcement", default: false },
  113. hidePanelMessage: { label: "Hide Panel Message", default: false },
  114. hidePostingForm: {
  115. label: "Hide Posting Form",
  116. default: false,
  117. subOptions: {
  118. showCatalogForm: {
  119. label: "Don't Hide in Catalog",
  120. default: false,
  121. },
  122. },
  123. },
  124. hideBanner: { label: "Hide Board Banners", default: false },
  125. },
  126. };
  127. const flatSettings = {};
  128. function flattenSettings() {
  129. Object.keys(scriptSettings).forEach((category) => {
  130. Object.keys(scriptSettings[category]).forEach((key) => {
  131. flatSettings[key] = scriptSettings[category][key];
  132. if (scriptSettings[category][key].subOptions) {
  133. Object.keys(scriptSettings[category][key].subOptions).forEach(
  134. (subKey) => {
  135. const fullKey = `${key}_${subKey}`;
  136. flatSettings[fullKey] =
  137. scriptSettings[category][key].subOptions[subKey];
  138. }
  139. );
  140. }
  141. });
  142. });
  143. }
  144. flattenSettings();
  145. async function getSetting(key) {
  146. if (!flatSettings[key]) {
  147. console.warn(`Setting key not found: ${key}`);
  148. return false;
  149. }
  150. let val = await GM.getValue("8chanSS_" + key, null);
  151. if (val === null) return flatSettings[key].default;
  152. if (flatSettings[key].type === "number") return Number(val);
  153. return val === "true";
  154. }
  155.  
  156. async function setSetting(key, value) {
  157. await GM.setValue("8chanSS_" + key, String(value));
  158. }
  159. async function featureCssClassToggles() {
  160. document.documentElement.classList.add("8chanSS");
  161. const classToggles = {
  162. enableFitReplies: "fit-replies",
  163. enableSidebar: "ss-sidebar",
  164. enableStickyQR: "sticky-qr",
  165. enableBottomHeader: "bottom-header",
  166. hideHiddenPostStub: "hide-stub",
  167. hideBanner: "disable-banner",
  168. hidePostingForm: "hide-posting-form",
  169. hidePostingForm_showCatalogForm: "show-catalog-form",
  170. hideAnnouncement: "hide-announcement",
  171. hidePanelMessage: "hide-panelmessage",
  172. highlightOnYou: "highlight-you",
  173. };
  174. for (const [settingKey, className] of Object.entries(classToggles)) {
  175. if (await getSetting(settingKey)) {
  176. document.documentElement.classList.add(className);
  177. } else {
  178. document.documentElement.classList.remove(className);
  179. }
  180. }
  181. const urlClassMap = [
  182. { pattern: /\/catalog\.html$/i, className: "is-catalog" },
  183. { pattern: /\/res\/[^/]+\.html$/i, className: "is-thread" },
  184. { pattern: /^\/$/, className: "is-index" },
  185. ];
  186. const currentPath = window.location.pathname.toLowerCase();
  187. urlClassMap.forEach(({ pattern, className }) => {
  188. if (pattern.test(currentPath)) {
  189. document.documentElement.classList.add(className);
  190. } else {
  191. document.documentElement.classList.remove(className);
  192. }
  193. });
  194. }
  195. featureCssClassToggles();
  196. const themeSelector = document.getElementById("themesBefore");
  197. let link = null;
  198. let bracketSpan = null;
  199. if (themeSelector) {
  200. bracketSpan = document.createElement("span");
  201. bracketSpan.textContent = "] [ ";
  202. link = document.createElement("a");
  203. link.id = "8chanSS-icon";
  204. link.href = "#";
  205. link.textContent = "8chanSS";
  206. link.style.fontWeight = "bold";
  207.  
  208. themeSelector.parentNode.insertBefore(
  209. bracketSpan,
  210. themeSelector.nextSibling
  211. );
  212. themeSelector.parentNode.insertBefore(link, bracketSpan.nextSibling);
  213. }
  214. function createShortcutsTab() {
  215. const container = document.createElement("div");
  216. const title = document.createElement("h3");
  217. title.textContent = "Keyboard Shortcuts";
  218. title.style.margin = "0 0 15px 0";
  219. title.style.fontSize = "16px";
  220. container.appendChild(title);
  221. const table = document.createElement("table");
  222. table.style.width = "100%";
  223. table.style.borderCollapse = "collapse";
  224. const tableStyles = {
  225. th: {
  226. textAlign: "left",
  227. padding: "8px 5px",
  228. borderBottom: "1px solid #444",
  229. fontSize: "14px",
  230. fontWeight: "bold",
  231. },
  232. td: {
  233. padding: "8px 5px",
  234. borderBottom: "1px solid #333",
  235. fontSize: "13px",
  236. },
  237. kbd: {
  238. background: "#333",
  239. border: "1px solid #555",
  240. borderRadius: "3px",
  241. padding: "2px 5px",
  242. fontSize: "12px",
  243. fontFamily: "monospace",
  244. },
  245. };
  246. const headerRow = document.createElement("tr");
  247. const shortcutHeader = document.createElement("th");
  248. shortcutHeader.textContent = "Shortcut";
  249. Object.assign(shortcutHeader.style, tableStyles.th);
  250. headerRow.appendChild(shortcutHeader);
  251.  
  252. const actionHeader = document.createElement("th");
  253. actionHeader.textContent = "Action";
  254. Object.assign(actionHeader.style, tableStyles.th);
  255. headerRow.appendChild(actionHeader);
  256.  
  257. table.appendChild(headerRow);
  258. const shortcuts = [
  259. { keys: ["Ctrl", "F1"], action: "Open 8chanSS settings" },
  260. { keys: ["Ctrl", "Q"], action: "Toggle Quick Reply" },
  261. { keys: ["Ctrl", "Enter"], action: "Submit post" },
  262. { keys: ["Ctrl", "W"], action: "Watch Thread" },
  263. { keys: ["Escape"], action: "Clear textarea and hide Quick Reply" },
  264. { keys: ["Ctrl", "B"], action: "Bold text" },
  265. { keys: ["Ctrl", "I"], action: "Italic text" },
  266. { keys: ["Ctrl", "U"], action: "Underline text" },
  267. { keys: ["Ctrl", "S"], action: "Spoiler text" },
  268. { keys: ["Ctrl", "D"], action: "Doom text" },
  269. { keys: ["Ctrl", "M"], action: "Moe text" },
  270. { keys: ["Alt", "C"], action: "Code block" },
  271. ];
  272. shortcuts.forEach((shortcut) => {
  273. const row = document.createElement("tr");
  274. const shortcutCell = document.createElement("td");
  275. Object.assign(shortcutCell.style, tableStyles.td);
  276. shortcut.keys.forEach((key, index) => {
  277. const kbd = document.createElement("kbd");
  278. kbd.textContent = key;
  279. Object.assign(kbd.style, tableStyles.kbd);
  280. shortcutCell.appendChild(kbd);
  281. if (index < shortcut.keys.length - 1) {
  282. const plus = document.createTextNode(" + ");
  283. shortcutCell.appendChild(plus);
  284. }
  285. });
  286.  
  287. row.appendChild(shortcutCell);
  288. const actionCell = document.createElement("td");
  289. actionCell.textContent = shortcut.action;
  290. Object.assign(actionCell.style, tableStyles.td);
  291. row.appendChild(actionCell);
  292.  
  293. table.appendChild(row);
  294. });
  295.  
  296. container.appendChild(table);
  297. const note = document.createElement("p");
  298. note.textContent =
  299. "Text formatting shortcuts work when text is selected or when inserting at cursor position.";
  300. note.style.fontSize = "12px";
  301. note.style.marginTop = "15px";
  302. note.style.opacity = "0.7";
  303. note.style.fontStyle = "italic";
  304. container.appendChild(note);
  305.  
  306. return container;
  307. }
  308. function addCustomCSS(css) {
  309. if (!css) return;
  310. const style = document.createElement("style");
  311. style.type = "text/css";
  312. style.id = "8chSS";
  313. style.appendChild(document.createTextNode(css));
  314. document.head.appendChild(style);
  315. }
  316. const currentPath = window.location.pathname.toLowerCase();
  317. const currentHost = window.location.hostname.toLowerCase();
  318. if (/^8chan\.(se|moe)$/.test(currentHost)) {
  319. const css = ":not(.is-catalog) body{margin:0}:root.ss-sidebar #mainPanel{margin-right:305px}#sideCatalogDiv{z-index:200;background:var(--background-gradient)}#navFadeEnd,#navFadeMid,#navTopBoardsSpan,:root.hide-announcement #dynamicAnnouncement,:root.hide-panelmessage #panelMessage,:root.hide-posting-form #postingForm{display:none}: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)}.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}#watchedMenu .watchedCellLabel>a:after{content:' - ' attr(href);filter:saturate(50%);font-style:italic;font-weight:700}#watchedMenu .watchedCellLabel>a::after{visibility:hidden}td.watchedCell>label.watchedCellLabel{text-overflow:ellipsis;overflow:hidden;white-space:nowrap;width:180px;display:block}td.watchedCell>label.watchedCellLabel:hover{overflow:unset;width:auto;white-space:normal}.watchedNotification::before{padding-right:2px}.scroll-arrow-btn{position:fixed;right:50px;width:36px;height:35px;background:#222;color:#fff;border:none;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,.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.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}";
  320. addCustomCSS(css);
  321. }
  322. if (/\/res\/[^/]+\.html$/.test(currentPath)) {
  323. const css = ":root.sticky-qr #quick-reply{display:block;top:auto!important;bottom:0;left:auto!important;position:fixed;right:0!important}:root.sticky-qr #qrbody{resize:vertical;max-height:50vh;height:130px}#qrbody{min-width:300px}:root.bottom-header #quick-reply{bottom:28px!important}#quick-reply{padding:0;opacity:.7;transition:opacity .3s ease}#quick-reply:focus-within,#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:305px;right:0;position:fixed;top:26px}:root.ss-sidebar.bottom-header #bannerImage{top:0!important}.quoteTooltip{z-index:999}.inlineQuote .replyPreview{margin-left:20px;border-left:1px solid #ccc;padding-left:10px}.nestedQuoteLink{text-decoration:underline dashed!important}:root.hide-stub .unhideButton{display:none}.quoteTooltip .innerPost{overflow:hidden;box-shadow:-3px 3px 2px 0 rgba(0,0,0,.19)}:root.fit-replies :not(.hidden).innerPost{margin-left:10px;display:flow-root}:root.fit-replies .quoteTooltip{display:table!important}:root.highlight-you .innerPost:has(.youName){border-left:dashed #68b723 3px}:root.highlight-you .innerPost:not(:has(.youName)):has(.quoteLink.you){border-left:solid #dd003e 3px}.originalNameLink{display:inline;overflow-wrap:anywhere;white-space:normal}.multipleUploads .uploadCell:not(.expandedCell){max-width:215px}.postCell::before{display:inline!important;height:auto!important}";
  324. addCustomCSS(css);
  325. }
  326. if (/\/catalog\.html$/.test(currentPath)) {
  327. const css = "#dynamicAnnouncement{display:none}#postingForm{margin:2em auto}";
  328. addCustomCSS(css);
  329. }
  330. async function createSettingsMenu() {
  331. let menu = document.getElementById("8chanSS-menu");
  332. if (menu) return menu;
  333. menu = document.createElement("div");
  334. menu.id = "8chanSS-menu";
  335. menu.style.position = "fixed";
  336. menu.style.top = "80px";
  337. menu.style.left = "30px";
  338. menu.style.zIndex = 99999;
  339. menu.style.background = "#222";
  340. menu.style.color = "#fff";
  341. menu.style.padding = "0";
  342. menu.style.borderRadius = "8px";
  343. menu.style.boxShadow = "0 4px 16px rgba(0,0,0,0.25)";
  344. menu.style.display = "none";
  345. menu.style.minWidth = "220px";
  346. menu.style.width = "100%";
  347. menu.style.maxWidth = "365px";
  348. menu.style.fontFamily = "sans-serif";
  349. menu.style.userSelect = "none";
  350. let isDragging = false,
  351. dragOffsetX = 0,
  352. dragOffsetY = 0;
  353. const header = document.createElement("div");
  354. header.style.display = "flex";
  355. header.style.justifyContent = "space-between";
  356. header.style.alignItems = "center";
  357. header.style.marginBottom = "0";
  358. header.style.cursor = "move";
  359. header.style.background = "#333";
  360. header.style.padding = "5px 18px 5px";
  361. header.style.borderTopLeftRadius = "8px";
  362. header.style.borderTopRightRadius = "8px";
  363. header.addEventListener("mousedown", function (e) {
  364. isDragging = true;
  365. const rect = menu.getBoundingClientRect();
  366. dragOffsetX = e.clientX - rect.left;
  367. dragOffsetY = e.clientY - rect.top;
  368. document.body.style.userSelect = "none";
  369. });
  370. document.addEventListener("mousemove", function (e) {
  371. if (!isDragging) return;
  372. let newLeft = e.clientX - dragOffsetX;
  373. let newTop = e.clientY - dragOffsetY;
  374. const menuRect = menu.getBoundingClientRect();
  375. const menuWidth = menuRect.width;
  376. const menuHeight = menuRect.height;
  377. const viewportWidth = window.innerWidth;
  378. const viewportHeight = window.innerHeight;
  379. newLeft = Math.max(0, Math.min(newLeft, viewportWidth - menuWidth));
  380. newTop = Math.max(0, Math.min(newTop, viewportHeight - menuHeight));
  381. menu.style.left = newLeft + "px";
  382. menu.style.top = newTop + "px";
  383. menu.style.right = "auto";
  384. });
  385. document.addEventListener("mouseup", function () {
  386. isDragging = false;
  387. document.body.style.userSelect = "";
  388. });
  389. const title = document.createElement("span");
  390. title.textContent = "8chanSS Settings";
  391. title.style.fontWeight = "bold";
  392. header.appendChild(title);
  393.  
  394. const closeBtn = document.createElement("button");
  395. closeBtn.textContent = "✕";
  396. closeBtn.style.background = "none";
  397. closeBtn.style.border = "none";
  398. closeBtn.style.color = "#fff";
  399. closeBtn.style.fontSize = "18px";
  400. closeBtn.style.cursor = "pointer";
  401. closeBtn.style.marginLeft = "10px";
  402. closeBtn.addEventListener("click", () => {
  403. menu.style.display = "none";
  404. });
  405. header.appendChild(closeBtn);
  406.  
  407. menu.appendChild(header);
  408. const tabNav = document.createElement("div");
  409. tabNav.style.display = "flex";
  410. tabNav.style.borderBottom = "1px solid #444";
  411. tabNav.style.background = "#2a2a2a";
  412. const tabContent = document.createElement("div");
  413. tabContent.style.padding = "15px 18px";
  414. tabContent.style.maxHeight = "60vh";
  415. tabContent.style.overflowY = "auto";
  416. const tempSettings = {};
  417. await Promise.all(
  418. Object.keys(flatSettings).map(async (key) => {
  419. tempSettings[key] = await getSetting(key);
  420. })
  421. );
  422. const tabs = {
  423. site: {
  424. label: "Site",
  425. content: createTabContent("site", tempSettings),
  426. },
  427. threads: {
  428. label: "Threads",
  429. content: createTabContent("threads", tempSettings),
  430. },
  431. catalog: {
  432. label: "Catalog",
  433. content: createTabContent("catalog", tempSettings),
  434. },
  435. styling: {
  436. label: "Style",
  437. content: createTabContent("styling", tempSettings),
  438. },
  439. shortcuts: {
  440. label: "⌨️",
  441. content: createShortcutsTab(),
  442. },
  443. };
  444. Object.keys(tabs).forEach((tabId, index, arr) => {
  445. const tab = tabs[tabId];
  446. const tabButton = document.createElement("button");
  447. tabButton.textContent = tab.label;
  448. tabButton.dataset.tab = tabId;
  449. tabButton.style.background = index === 0 ? "#333" : "transparent";
  450. tabButton.style.border = "none";
  451. tabButton.style.borderRight = "1px solid #444";
  452. tabButton.style.color = "#fff";
  453. tabButton.style.padding = "8px 15px";
  454. tabButton.style.margin = "5px 0 0 0";
  455. tabButton.style.cursor = "pointer";
  456. tabButton.style.flex = "1";
  457. tabButton.style.fontSize = "14px";
  458. tabButton.style.transition = "background 0.2s";
  459. if (index === 0) {
  460. tabButton.style.borderTopLeftRadius = "8px";
  461. tabButton.style.margin = "5px 0 0 5px";
  462. }
  463. if (index === arr.length - 1) {
  464. tabButton.style.borderTopRightRadius = "8px";
  465. tabButton.style.margin = "5px 5px 0 0";
  466. tabButton.style.borderRight = "none";
  467. }
  468.  
  469. tabButton.addEventListener("click", () => {
  470. Object.values(tabs).forEach((t) => {
  471. t.content.style.display = "none";
  472. });
  473. tab.content.style.display = "block";
  474. tabNav.querySelectorAll("button").forEach((btn) => {
  475. btn.style.background = "transparent";
  476. });
  477. tabButton.style.background = "#333";
  478. });
  479.  
  480. tabNav.appendChild(tabButton);
  481. });
  482.  
  483. menu.appendChild(tabNav);
  484. Object.values(tabs).forEach((tab, index) => {
  485. tab.content.style.display = index === 0 ? "block" : "none";
  486. tabContent.appendChild(tab.content);
  487. });
  488.  
  489. menu.appendChild(tabContent);
  490. const buttonContainer = document.createElement("div");
  491. buttonContainer.style.display = "flex";
  492. buttonContainer.style.gap = "10px";
  493. buttonContainer.style.padding = "0 18px 15px";
  494. const saveBtn = document.createElement("button");
  495. saveBtn.textContent = "Save";
  496. saveBtn.style.background = "#4caf50";
  497. saveBtn.style.color = "#fff";
  498. saveBtn.style.border = "none";
  499. saveBtn.style.borderRadius = "4px";
  500. saveBtn.style.padding = "8px 18px";
  501. saveBtn.style.fontSize = "15px";
  502. saveBtn.style.cursor = "pointer";
  503. saveBtn.style.flex = "1";
  504. saveBtn.addEventListener("click", async function () {
  505. for (const key of Object.keys(tempSettings)) {
  506. await setSetting(key, tempSettings[key]);
  507. }
  508. saveBtn.textContent = "Saved!";
  509. setTimeout(() => {
  510. saveBtn.textContent = "Save";
  511. }, 900);
  512. setTimeout(() => {
  513. window.location.reload();
  514. }, 400);
  515. });
  516. buttonContainer.appendChild(saveBtn);
  517. const resetBtn = document.createElement("button");
  518. resetBtn.textContent = "Reset";
  519. resetBtn.style.background = "#dd3333";
  520. resetBtn.style.color = "#fff";
  521. resetBtn.style.border = "none";
  522. resetBtn.style.borderRadius = "4px";
  523. resetBtn.style.padding = "8px 18px";
  524. resetBtn.style.fontSize = "15px";
  525. resetBtn.style.cursor = "pointer";
  526. resetBtn.style.flex = "1";
  527. resetBtn.addEventListener("click", async function () {
  528. if (confirm("Reset all 8chanSS settings to defaults?")) {
  529. const keys = await GM.listValues();
  530. for (const key of keys) {
  531. if (key.startsWith("8chanSS_")) {
  532. await GM.deleteValue(key);
  533. }
  534. }
  535. resetBtn.textContent = "Reset!";
  536. setTimeout(() => {
  537. resetBtn.textContent = "Reset";
  538. }, 900);
  539. setTimeout(() => {
  540. window.location.reload();
  541. }, 400);
  542. }
  543. });
  544. buttonContainer.appendChild(resetBtn);
  545.  
  546. menu.appendChild(buttonContainer);
  547. const info = document.createElement("div");
  548. info.style.fontSize = "11px";
  549. info.style.padding = "0 18px 12px";
  550. info.style.opacity = "0.7";
  551. info.style.textAlign = "center";
  552. info.textContent = "Press Save to apply changes. Page will reload.";
  553. menu.appendChild(info);
  554.  
  555. document.body.appendChild(menu);
  556. return menu;
  557. }
  558. function createTabContent(category, tempSettings) {
  559. const container = document.createElement("div");
  560. const categorySettings = scriptSettings[category];
  561.  
  562. Object.keys(categorySettings).forEach((key) => {
  563. const setting = categorySettings[key];
  564. const parentRow = document.createElement("div");
  565. parentRow.style.display = "flex";
  566. parentRow.style.alignItems = "center";
  567. parentRow.style.marginBottom = "0px";
  568. if (key === "hoverVideoVolume" && setting.type === "number") {
  569. const label = document.createElement("label");
  570. label.htmlFor = "setting_" + key;
  571. label.textContent = setting.label + ": ";
  572. label.style.flex = "1";
  573.  
  574. const sliderContainer = document.createElement("div");
  575. sliderContainer.style.display = "flex";
  576. sliderContainer.style.alignItems = "center";
  577. sliderContainer.style.flex = "1";
  578.  
  579. const slider = document.createElement("input");
  580. slider.type = "range";
  581. slider.id = "setting_" + key;
  582. slider.min = setting.min;
  583. slider.max = setting.max;
  584. slider.value = Number(tempSettings[key]);
  585. slider.style.flex = "unset";
  586. slider.style.width = "100px";
  587. slider.style.marginRight = "10px";
  588.  
  589. const valueLabel = document.createElement("span");
  590. valueLabel.textContent = slider.value + "%";
  591. valueLabel.style.minWidth = "40px";
  592. valueLabel.style.textAlign = "right";
  593.  
  594. slider.addEventListener("input", function () {
  595. let val = Number(slider.value);
  596. if (isNaN(val)) val = setting.default;
  597. val = Math.max(setting.min, Math.min(setting.max, val));
  598. slider.value = val;
  599. tempSettings[key] = val;
  600. valueLabel.textContent = val + "%";
  601. });
  602.  
  603. sliderContainer.appendChild(slider);
  604. sliderContainer.appendChild(valueLabel);
  605.  
  606. parentRow.appendChild(label);
  607. parentRow.appendChild(sliderContainer);
  608. const wrapper = document.createElement("div");
  609. wrapper.style.marginBottom = "10px";
  610. wrapper.appendChild(parentRow);
  611. container.appendChild(wrapper);
  612. return;
  613. }
  614. const checkbox = document.createElement("input");
  615. checkbox.type = "checkbox";
  616. checkbox.id = "setting_" + key;
  617. checkbox.checked =
  618. tempSettings[key] === true || tempSettings[key] === "true";
  619. checkbox.style.marginRight = "8px";
  620. const label = document.createElement("label");
  621. label.htmlFor = checkbox.id;
  622. label.textContent = setting.label;
  623. label.style.flex = "1";
  624. let chevron = null;
  625. let subOptionsContainer = null;
  626. if (setting.subOptions) {
  627. chevron = document.createElement("span");
  628. chevron.className = "ss-chevron";
  629. chevron.innerHTML = "&#9654;";
  630. chevron.style.display = "inline-block";
  631. chevron.style.transition = "transform 0.2s";
  632. chevron.style.marginLeft = "6px";
  633. chevron.style.fontSize = "12px";
  634. chevron.style.userSelect = "none";
  635. chevron.style.transform = checkbox.checked
  636. ? "rotate(90deg)"
  637. : "rotate(0deg)";
  638. }
  639. checkbox.addEventListener("change", function () {
  640. tempSettings[key] = checkbox.checked;
  641. if (setting.subOptions && subOptionsContainer) {
  642. subOptionsContainer.style.display = checkbox.checked
  643. ? "block"
  644. : "none";
  645. if (chevron) {
  646. chevron.style.transform = checkbox.checked
  647. ? "rotate(90deg)"
  648. : "rotate(0deg)";
  649. }
  650. }
  651. });
  652.  
  653. parentRow.appendChild(checkbox);
  654. parentRow.appendChild(label);
  655. if (chevron) parentRow.appendChild(chevron);
  656. const wrapper = document.createElement("div");
  657. wrapper.style.marginBottom = "10px";
  658.  
  659. wrapper.appendChild(parentRow);
  660. if (setting.subOptions) {
  661. subOptionsContainer = document.createElement("div");
  662. subOptionsContainer.style.marginLeft = "25px";
  663. subOptionsContainer.style.marginTop = "5px";
  664. subOptionsContainer.style.display = checkbox.checked ? "block" : "none";
  665.  
  666. Object.keys(setting.subOptions).forEach((subKey) => {
  667. const subSetting = setting.subOptions[subKey];
  668. const fullKey = `${key}_${subKey}`;
  669.  
  670. const subWrapper = document.createElement("div");
  671. subWrapper.style.marginBottom = "5px";
  672.  
  673. const subCheckbox = document.createElement("input");
  674. subCheckbox.type = "checkbox";
  675. subCheckbox.id = "setting_" + fullKey;
  676. subCheckbox.checked = tempSettings[fullKey];
  677. subCheckbox.style.marginRight = "8px";
  678.  
  679. subCheckbox.addEventListener("change", function () {
  680. tempSettings[fullKey] = subCheckbox.checked;
  681. });
  682.  
  683. const subLabel = document.createElement("label");
  684. subLabel.htmlFor = subCheckbox.id;
  685. subLabel.textContent = subSetting.label;
  686.  
  687. subWrapper.appendChild(subCheckbox);
  688. subWrapper.appendChild(subLabel);
  689. subOptionsContainer.appendChild(subWrapper);
  690. });
  691.  
  692. wrapper.appendChild(subOptionsContainer);
  693. }
  694.  
  695. container.appendChild(wrapper);
  696. });
  697. if (!document.getElementById("ss-chevron-style")) {
  698. const style = document.createElement("style");
  699. style.id = "ss-chevron-style";
  700. style.textContent = `
  701. .ss-chevron {
  702. transition: transform 0.2s;
  703. margin-left: 6px;
  704. font-size: 12px;
  705. display: inline-block;
  706. }
  707. `;
  708. document.head.appendChild(style);
  709. }
  710.  
  711. return container;
  712. }
  713. if (link) {
  714. let menu = await createSettingsMenu();
  715. link.style.cursor = "pointer";
  716. link.title = "Open 8chanSS settings";
  717. link.addEventListener("click", async function (e) {
  718. e.preventDefault();
  719. let menu = await createSettingsMenu();
  720. menu.style.display = menu.style.display === "none" ? "block" : "none";
  721. });
  722. }
  723. async function featureHeaderCatalogLinks() {
  724. async function appendCatalogToLinks() {
  725. const navboardsSpan = document.getElementById("navBoardsSpan");
  726. if (navboardsSpan) {
  727. const links = navboardsSpan.getElementsByTagName("a");
  728. const openInNewTab = await getSetting(
  729. "enableHeaderCatalogLinks_openInNewTab"
  730. );
  731.  
  732. for (let link of links) {
  733. if (link.href && !link.href.endsWith("/catalog.html")) {
  734. link.href += "/catalog.html";
  735. if (openInNewTab) {
  736. link.target = "_blank";
  737. link.rel = "noopener noreferrer";
  738. } else {
  739. link.target = "";
  740. link.rel = "";
  741. }
  742. }
  743. }
  744. }
  745. }
  746.  
  747. appendCatalogToLinks();
  748. const observer = new MutationObserver(appendCatalogToLinks);
  749. const config = { childList: true, subtree: true };
  750. const navboardsSpan = document.getElementById("navBoardsSpan");
  751. if (navboardsSpan) {
  752. observer.observe(navboardsSpan, config);
  753. }
  754. }
  755. async function featureImageHover() {
  756. function getFullMediaSrcFromMime(thumbNode, filemime) {
  757. if (!thumbNode || !filemime) return null;
  758. const thumbnailSrc = thumbNode.getAttribute("src");
  759. if (/\/t_/.test(thumbnailSrc)) {
  760. let base = thumbnailSrc.replace(/\/t_/, "/");
  761. base = base.replace(/\.(jpe?g|png|gif|webp|webm|mp4|webm|ogg|mp3|m4a|wav)$/i, "");
  762. const mimeToExt = {
  763. "image/jpeg": ".jpg",
  764. "image/jpg": ".jpg",
  765. "image/png": ".png",
  766. "image/gif": ".gif",
  767. "image/webp": ".webp",
  768. "image/bmp": ".bmp",
  769. "video/mp4": ".mp4",
  770. "video/webm": ".webm",
  771. "audio/ogg": ".ogg",
  772. "audio/mpeg": ".mp3",
  773. "audio/x-m4a": ".m4a",
  774. "audio/wav": ".wav",
  775. };
  776. const ext = mimeToExt[filemime.toLowerCase()];
  777. if (!ext) return null;
  778. return base + ext;
  779. }
  780. if (
  781. /\/spoiler\.png$/i.test(thumbnailSrc) ||
  782. /\/custom\.spoiler$/i.test(thumbnailSrc) ||
  783. /\/audioGenericThumb\.png$/i.test(thumbnailSrc)
  784. ) {
  785. const parentA = thumbNode.closest("a.linkThumb, a.imgLink");
  786. if (parentA && parentA.getAttribute("href")) {
  787. return parentA.getAttribute("href");
  788. }
  789. return null;
  790. }
  791. return null;
  792. }
  793. if (!document.getElementById("audio-preview-indicator-style")) {
  794. const style = document.createElement("style");
  795. style.id = "audio-preview-indicator-style";
  796. style.textContent = `
  797. a.imgLink[data-filemime^="audio/"],
  798. a.originalNameLink[href$=".mp3"],
  799. a.originalNameLink[href$=".ogg"],
  800. a.originalNameLink[href$=".m4a"],
  801. a.originalNameLink[href$=".wav"] {
  802. position: relative;
  803. }
  804. .audio-preview-indicator {
  805. display: none;
  806. position: absolute;
  807. background: rgba(0, 0, 0, 0.7);
  808. color: #ffffff;
  809. padding: 5px;
  810. font-size: 12px;
  811. border-radius: 3px;
  812. z-index: 1000;
  813. left: 0;
  814. top: 0;
  815. white-space: nowrap;
  816. pointer-events: none;
  817. }
  818. a[data-filemime^="audio/"]:hover .audio-preview-indicator,
  819. a.originalNameLink:hover .audio-preview-indicator {
  820. display: block;
  821. }
  822. `;
  823. document.head.appendChild(style);
  824. }
  825.  
  826. let floatingMedia = null;
  827. let removeListeners = null;
  828. let hoverTimeout = null;
  829. let lastThumb = null;
  830. let isStillHovering = false;
  831.  
  832. function cleanupFloatingMedia() {
  833. if (hoverTimeout) {
  834. clearTimeout(hoverTimeout);
  835. hoverTimeout = null;
  836. }
  837.  
  838. if (removeListeners) {
  839. removeListeners();
  840. removeListeners = null;
  841. }
  842.  
  843. if (floatingMedia) {
  844. if (
  845. floatingMedia.tagName === "VIDEO" ||
  846. floatingMedia.tagName === "AUDIO"
  847. ) {
  848. try {
  849. floatingMedia.pause();
  850. floatingMedia.removeAttribute("src");
  851. floatingMedia.load();
  852. } catch (e) {
  853. }
  854. }
  855.  
  856. if (floatingMedia.parentNode) {
  857. floatingMedia.parentNode.removeChild(floatingMedia);
  858. }
  859. }
  860. const indicators = document.querySelectorAll(".audio-preview-indicator");
  861. indicators.forEach((indicator) => {
  862. if (indicator.parentNode) {
  863. indicator.parentNode.removeChild(indicator);
  864. }
  865. });
  866.  
  867. floatingMedia = null;
  868. lastThumb = null;
  869. isStillHovering = false;
  870. document.removeEventListener("mousemove", onMouseMove);
  871. }
  872.  
  873. function onMouseMove(event) {
  874. if (!floatingMedia) return;
  875.  
  876. const viewportWidth = window.innerWidth;
  877. const viewportHeight = window.innerHeight;
  878. let mediaWidth = 0,
  879. mediaHeight = 0;
  880.  
  881. if (floatingMedia.tagName === "IMG") {
  882. mediaWidth =
  883. floatingMedia.naturalWidth ||
  884. floatingMedia.width ||
  885. floatingMedia.offsetWidth ||
  886. 0;
  887. mediaHeight =
  888. floatingMedia.naturalHeight ||
  889. floatingMedia.height ||
  890. floatingMedia.offsetHeight ||
  891. 0;
  892. } else if (floatingMedia.tagName === "VIDEO") {
  893. mediaWidth = floatingMedia.videoWidth || floatingMedia.offsetWidth || 0;
  894. mediaHeight =
  895. floatingMedia.videoHeight || floatingMedia.offsetHeight || 0;
  896. } else if (floatingMedia.tagName === "AUDIO") {
  897. return;
  898. }
  899.  
  900. mediaWidth = Math.min(mediaWidth, viewportWidth * 0.9);
  901. mediaHeight = Math.min(mediaHeight, viewportHeight * 0.9);
  902.  
  903. let newX = event.clientX + 10;
  904. let newY = event.clientY + 10;
  905.  
  906. if (newX + mediaWidth > viewportWidth) {
  907. newX = viewportWidth - mediaWidth - 10;
  908. }
  909. if (newY + mediaHeight > viewportHeight) {
  910. newY = viewportHeight - mediaHeight - 10;
  911. }
  912.  
  913. newX = Math.max(newX, 0);
  914. newY = Math.max(newY, 0);
  915.  
  916. floatingMedia.style.left = `${newX}px`;
  917. floatingMedia.style.top = `${newY}px`;
  918. floatingMedia.style.maxWidth = "90vw";
  919. floatingMedia.style.maxHeight = "90vh";
  920. }
  921.  
  922. async function onThumbEnter(e) {
  923. const thumb = e.currentTarget;
  924. if (lastThumb === thumb) return;
  925. lastThumb = thumb;
  926.  
  927. cleanupFloatingMedia();
  928. isStillHovering = true;
  929. const container =
  930. thumb.tagName === "IMG"
  931. ? thumb.closest("a.linkThumb, a.imgLink")
  932. : thumb;
  933.  
  934. function onLeave() {
  935. isStillHovering = false;
  936. cleanupFloatingMedia();
  937. }
  938.  
  939. thumb.addEventListener("mouseleave", onLeave, { once: true });
  940.  
  941. hoverTimeout = setTimeout(async () => {
  942. hoverTimeout = null;
  943. if (!isStillHovering) return;
  944.  
  945. let filemime = null;
  946. let fullSrc = null;
  947. if (thumb.tagName === "IMG") {
  948. const parentA = thumb.closest("a.linkThumb, a.imgLink");
  949. if (!parentA) return;
  950.  
  951. const href = parentA.getAttribute("href");
  952. if (!href) return;
  953.  
  954. const ext = href.split(".").pop().toLowerCase();
  955. filemime =
  956. parentA.getAttribute("data-filemime") ||
  957. {
  958. jpg: "image/jpeg",
  959. jpeg: "image/jpeg",
  960. png: "image/png",
  961. gif: "image/gif",
  962. webp: "image/webp",
  963. bmp: "image/bmp",
  964. mp4: "video/mp4",
  965. webm: "video/webm",
  966. ogg: "audio/ogg",
  967. mp3: "audio/mpeg",
  968. m4a: "audio/x-m4a",
  969. wav: "audio/wav",
  970. }[ext];
  971.  
  972. fullSrc = getFullMediaSrcFromMime(thumb, filemime);
  973. }
  974. else if (thumb.classList.contains("originalNameLink")) {
  975. const href = thumb.getAttribute("href");
  976. if (!href) return;
  977.  
  978. const ext = href.split(".").pop().toLowerCase();
  979. if (["mp3", "ogg", "m4a", "wav"].includes(ext)) {
  980. filemime = {
  981. ogg: "audio/ogg",
  982. mp3: "audio/mpeg",
  983. m4a: "audio/x-m4a",
  984. wav: "audio/wav",
  985. }[ext];
  986. fullSrc = href;
  987. }
  988. }
  989.  
  990. if (!fullSrc || !filemime) return;
  991.  
  992. let loaded = false;
  993. function setCommonStyles(el) {
  994. el.style.position = "fixed";
  995. el.style.zIndex = 9999;
  996. el.style.pointerEvents = "none";
  997. el.style.maxWidth = "95vw";
  998. el.style.maxHeight = "95vh";
  999. el.style.transition = "opacity 0.15s";
  1000. el.style.opacity = "0";
  1001. el.style.left = "-9999px";
  1002. }
  1003. removeListeners = function () {
  1004. window.removeEventListener("scroll", cleanupFloatingMedia, true);
  1005. };
  1006. window.addEventListener("scroll", cleanupFloatingMedia, true);
  1007.  
  1008. if (filemime.startsWith("image/")) {
  1009. floatingMedia = document.createElement("img");
  1010. setCommonStyles(floatingMedia);
  1011.  
  1012. floatingMedia.onload = function () {
  1013. if (!loaded && floatingMedia && isStillHovering) {
  1014. loaded = true;
  1015. floatingMedia.style.opacity = "1";
  1016. document.body.appendChild(floatingMedia);
  1017. document.addEventListener("mousemove", onMouseMove);
  1018. onMouseMove(e);
  1019. }
  1020. };
  1021.  
  1022. floatingMedia.onerror = cleanupFloatingMedia;
  1023. floatingMedia.src = fullSrc;
  1024. } else if (filemime.startsWith("video/")) {
  1025. floatingMedia = document.createElement("video");
  1026. setCommonStyles(floatingMedia);
  1027.  
  1028. floatingMedia.autoplay = true;
  1029. floatingMedia.loop = true;
  1030. floatingMedia.muted = false;
  1031. floatingMedia.playsInline = true;
  1032. floatingMedia.controls = false;
  1033. let volume = 50;
  1034. try {
  1035. if (typeof getSetting === "function") {
  1036. const v = await getSetting("hoverVideoVolume");
  1037. if (typeof v === "number" && !isNaN(v)) {
  1038. volume = v;
  1039. }
  1040. }
  1041. } catch (e) {
  1042. volume = 50;
  1043. }
  1044. floatingMedia.volume = Math.max(0, Math.min(1, volume / 100));
  1045.  
  1046. floatingMedia.onloadeddata = function () {
  1047. if (!loaded && floatingMedia && isStillHovering) {
  1048. loaded = true;
  1049. floatingMedia.style.opacity = "1";
  1050. document.body.appendChild(floatingMedia);
  1051. document.addEventListener("mousemove", onMouseMove);
  1052. onMouseMove(e);
  1053. }
  1054. };
  1055.  
  1056. floatingMedia.onerror = cleanupFloatingMedia;
  1057. floatingMedia.src = fullSrc;
  1058. } else if (filemime.startsWith("audio/")) {
  1059. const oldIndicator = container.querySelector(
  1060. ".audio-preview-indicator"
  1061. );
  1062. if (oldIndicator) oldIndicator.remove();
  1063. if (container && !container.style.position) {
  1064. container.style.position = "relative";
  1065. }
  1066.  
  1067. floatingMedia = document.createElement("audio");
  1068. floatingMedia.src = fullSrc;
  1069. floatingMedia.controls = false;
  1070. floatingMedia.style.display = "none";
  1071. let volume = 50;
  1072. try {
  1073. if (typeof getSetting === "function") {
  1074. const v = await getSetting("hoverVideoVolume");
  1075. if (typeof v === "number" && !isNaN(v)) {
  1076. volume = v;
  1077. }
  1078. }
  1079. } catch (e) {
  1080. volume = 50;
  1081. }
  1082. floatingMedia.volume = Math.max(0, Math.min(1, volume / 100));
  1083.  
  1084. document.body.appendChild(floatingMedia);
  1085. const indicator = document.createElement("div");
  1086. indicator.classList.add("audio-preview-indicator");
  1087. indicator.textContent = "▶ Playing audio...";
  1088. container.appendChild(indicator);
  1089.  
  1090. floatingMedia.play().catch((error) => {
  1091. console.error("Audio playback failed:", error);
  1092. });
  1093. function removeAudioAndIndicator() {
  1094. if (floatingMedia) {
  1095. floatingMedia.pause();
  1096. floatingMedia.currentTime = 0;
  1097. floatingMedia.remove();
  1098. floatingMedia = null;
  1099. }
  1100. if (indicator) {
  1101. indicator.remove();
  1102. }
  1103. }
  1104.  
  1105. container.addEventListener("click", removeAudioAndIndicator, {
  1106. once: true,
  1107. });
  1108. }
  1109. }, 120);
  1110. }
  1111.  
  1112. function attachThumbListeners(root = document) {
  1113. const thumbs = root.querySelectorAll(
  1114. "a.linkThumb > img, a.imgLink > img"
  1115. );
  1116. thumbs.forEach((thumb) => {
  1117. if (!thumb._fullImgHoverBound) {
  1118. thumb.addEventListener("mouseenter", onThumbEnter);
  1119. thumb._fullImgHoverBound = true;
  1120. }
  1121. });
  1122. const audioLinks = root.querySelectorAll("a.originalNameLink");
  1123. audioLinks.forEach((link) => {
  1124. const href = link.getAttribute("href") || "";
  1125. const ext = href.split(".").pop().toLowerCase();
  1126. if (
  1127. ["mp3", "wav", "ogg", "m4a"].includes(ext) &&
  1128. !link._audioHoverBound
  1129. ) {
  1130. link.addEventListener("mouseenter", onThumbEnter);
  1131. link._audioHoverBound = true;
  1132. }
  1133. });
  1134. }
  1135. attachThumbListeners();
  1136. const observer = new MutationObserver((mutations) => {
  1137. for (const mutation of mutations) {
  1138. for (const node of mutation.addedNodes) {
  1139. if (node.nodeType === Node.ELEMENT_NODE) {
  1140. attachThumbListeners(node);
  1141. }
  1142. }
  1143. }
  1144. });
  1145.  
  1146. observer.observe(document.body, { childList: true, subtree: true });
  1147. }
  1148. function featureBlurSpoilers() {
  1149. function revealSpoilers() {
  1150. const spoilerLinks = document.querySelectorAll("a.imgLink");
  1151. spoilerLinks.forEach(async (link) => {
  1152. const img = link.querySelector("img");
  1153. if (img) {
  1154. const isCustomSpoiler = img.src.includes("/custom.spoiler");
  1155. const isNotThumbnail = !img.src.includes("/.media/t_");
  1156.  
  1157. if (isNotThumbnail || isCustomSpoiler) {
  1158. let href = link.getAttribute("href");
  1159. if (href) {
  1160. const match = href.match(/\/\.media\/([^\/]+)\.[a-zA-Z0-9]+$/);
  1161. if (match) {
  1162. const transformedSrc = `/\.media/t_${match[1]}`;
  1163. img.src = transformedSrc;
  1164. if (await getSetting("blurSpoilers_removeSpoilers")) {
  1165. img.style.filter = "";
  1166. img.style.transition = "";
  1167. img.onmouseover = null;
  1168. img.onmouseout = null;
  1169. return;
  1170. } else {
  1171. img.style.filter = "blur(5px)";
  1172. img.style.transition = "filter 0.3s ease";
  1173. img.addEventListener("mouseover", () => {
  1174. img.style.filter = "none";
  1175. });
  1176. img.addEventListener("mouseout", () => {
  1177. img.style.filter = "blur(5px)";
  1178. });
  1179. }
  1180. }
  1181. }
  1182. }
  1183. }
  1184. });
  1185. }
  1186. revealSpoilers();
  1187. const observer = new MutationObserver(revealSpoilers);
  1188. observer.observe(document.body, { childList: true, subtree: true });
  1189. }
  1190. async function featureWatchThreadOnReply() {
  1191. function getWatchButton() {
  1192. return document.querySelector(".watchButton");
  1193. }
  1194. function isThreadWatched() {
  1195. const btn = getWatchButton();
  1196. return btn && btn.classList.contains("watched-active");
  1197. }
  1198. function watchThreadIfNotWatched() {
  1199. const btn = getWatchButton();
  1200. if (btn && !isThreadWatched()) {
  1201. btn.click();
  1202. setTimeout(() => {
  1203. btn.classList.add("watched-active");
  1204. }, 100);
  1205. }
  1206. }
  1207. const submitButton = document.getElementById("qrbutton");
  1208. if (submitButton) {
  1209. submitButton.addEventListener("click", async function () {
  1210. if (await getSetting("watchThreadOnReply")) {
  1211. setTimeout(watchThreadIfNotWatched, 500);
  1212. }
  1213. });
  1214. }
  1215. function updateWatchButtonClass() {
  1216. const btn = getWatchButton();
  1217. if (!btn) return;
  1218. if (isThreadWatched()) {
  1219. btn.classList.add("watched-active");
  1220. } else {
  1221. btn.classList.remove("watched-active");
  1222. }
  1223. }
  1224. updateWatchButtonClass();
  1225. const btn = getWatchButton();
  1226. if (btn) {
  1227. btn.addEventListener("click", function () {
  1228. setTimeout(updateWatchButtonClass, 100);
  1229. });
  1230. }
  1231. }
  1232. document.addEventListener("keydown", async function (event) {
  1233. if (
  1234. event.altKey &&
  1235. !event.ctrlKey &&
  1236. !event.shiftKey &&
  1237. !event.metaKey &&
  1238. (event.key === "w" || event.key === "W")
  1239. ) {
  1240. event.preventDefault();
  1241. if (
  1242. typeof getSetting === "function" &&
  1243. (await getSetting("watchThreadOnReply"))
  1244. ) {
  1245. const btn = document.querySelector(".watchButton");
  1246. if (btn && !btn.classList.contains("watched-active")) {
  1247. btn.click();
  1248. setTimeout(() => {
  1249. btn.classList.add("watched-active");
  1250. }, 100);
  1251. }
  1252. }
  1253. }
  1254. });
  1255. async function featureAlwaysShowTW() {
  1256. if (!(await getSetting("alwaysShowTW"))) return;
  1257.  
  1258. function showThreadWatcher() {
  1259. const watchedMenu = document.getElementById("watchedMenu");
  1260. if (watchedMenu) {
  1261. watchedMenu.style.display = "flex";
  1262. }
  1263. }
  1264.  
  1265. function addCloseListener() {
  1266. const watchedMenu = document.getElementById("watchedMenu");
  1267. if (!watchedMenu) return;
  1268. const closeBtn = watchedMenu.querySelector(".close-btn");
  1269. if (closeBtn) {
  1270. closeBtn.addEventListener("click", () => {
  1271. watchedMenu.style.display = "none";
  1272. });
  1273. }
  1274. }
  1275. if (document.readyState === "loading") {
  1276. document.addEventListener("DOMContentLoaded", () => {
  1277. showThreadWatcher();
  1278. addCloseListener();
  1279. });
  1280. } else {
  1281. showThreadWatcher();
  1282. addCloseListener();
  1283. }
  1284. }
  1285. function processWatchedLabels() {
  1286. document.querySelectorAll('.watchedCellLabel').forEach(label => {
  1287. if (!label.isConnected) return;
  1288.  
  1289. const notif = label.querySelector('.watchedNotification');
  1290. const link = label.querySelector('a');
  1291. if (!notif || !link) return;
  1292. if (label.firstElementChild !== notif) {
  1293. label.prepend(notif);
  1294. }
  1295. const match = link.getAttribute('href').match(/^\/([^\/]+)\//);
  1296. if (!match) return;
  1297. const board = `/${match[1]}/`;
  1298. link.textContent = link.textContent.replace(/^\([^)]+\)\s*-\s*|^\/[^\/]+\/\s*-\s*/i, '');
  1299. link.textContent = `${board} - ${link.textContent}`;
  1300. });
  1301. }
  1302. processWatchedLabels();
  1303. function getWatchedContainer() {
  1304. return document.querySelector('.floatingContainer, #watchedThreads, .watchedThreads');
  1305. }
  1306.  
  1307. const container = getWatchedContainer();
  1308. if (container) {
  1309. const observer = new MutationObserver(() => {
  1310. processWatchedLabels();
  1311. });
  1312. observer.observe(container, { childList: true, subtree: true });
  1313. }
  1314. window.addEventListener('DOMContentLoaded', processWatchedLabels);
  1315. window.addEventListener('load', processWatchedLabels);
  1316. function featureScrollArrows() {
  1317. if (
  1318. document.getElementById("scroll-arrow-up") ||
  1319. document.getElementById("scroll-arrow-down")
  1320. )
  1321. return;
  1322. const upBtn = document.createElement("button");
  1323. upBtn.id = "scroll-arrow-up";
  1324. upBtn.className = "scroll-arrow-btn";
  1325. upBtn.title = "Scroll to top";
  1326. upBtn.innerHTML = "▲";
  1327. upBtn.addEventListener("click", () => {
  1328. window.scrollTo({ top: 0, behavior: "smooth" });
  1329. });
  1330. const downBtn = document.createElement("button");
  1331. downBtn.id = "scroll-arrow-down";
  1332. downBtn.className = "scroll-arrow-btn";
  1333. downBtn.title = "Scroll to bottom";
  1334. downBtn.innerHTML = "▼";
  1335. downBtn.addEventListener("click", () => {
  1336. const footer = document.getElementById("footer");
  1337. if (footer) {
  1338. footer.scrollIntoView({ behavior: "smooth", block: "end" });
  1339. } else {
  1340. window.scrollTo({
  1341. top: document.body.scrollHeight,
  1342. behavior: "smooth",
  1343. });
  1344. }
  1345. });
  1346.  
  1347. document.body.appendChild(upBtn);
  1348. document.body.appendChild(downBtn);
  1349. }
  1350. async function featureSaveScroll() {
  1351. if (document.documentElement.classList.contains("is-index")) return;
  1352.  
  1353. const MAX_PAGES = 50;
  1354. const currentPage = window.location.href;
  1355. const excludedPagePatterns = [
  1356. /\/catalog\.html$/i,
  1357. /\/.media\/$/i,
  1358. /\/boards\.js$/i,
  1359. /\/login\.html$/i,
  1360. /\/overboard$/i,
  1361. /\/sfw$/i
  1362. ];
  1363.  
  1364. function isExcludedPage(url) {
  1365. return excludedPagePatterns.some((pattern) => pattern.test(url));
  1366. }
  1367.  
  1368. async function saveScrollPosition() {
  1369. if (isExcludedPage(currentPage)) return;
  1370.  
  1371. const scrollPosition = window.scrollY;
  1372. const timestamp = Date.now();
  1373. await GM.setValue(
  1374. `8chanSS_scrollPosition_${currentPage}`,
  1375. JSON.stringify({
  1376. position: scrollPosition,
  1377. timestamp: timestamp,
  1378. })
  1379. );
  1380.  
  1381. await manageScrollStorage();
  1382. }
  1383.  
  1384. async function manageScrollStorage() {
  1385. const allKeys = await GM.listValues();
  1386. const scrollKeys = allKeys.filter((key) =>
  1387. key.startsWith("8chanSS_scrollPosition_")
  1388. );
  1389.  
  1390. if (scrollKeys.length > MAX_PAGES) {
  1391. const keyData = await Promise.all(
  1392. scrollKeys.map(async (key) => {
  1393. let data;
  1394. try {
  1395. const savedValue = await GM.getValue(key, null);
  1396. data = savedValue ? JSON.parse(savedValue) : { position: 0, timestamp: 0 };
  1397. } catch (e) {
  1398. data = { position: 0, timestamp: 0 };
  1399. }
  1400. return {
  1401. key: key,
  1402. timestamp: data.timestamp || 0,
  1403. };
  1404. })
  1405. );
  1406. keyData.sort((a, b) => a.timestamp - b.timestamp);
  1407. const keysToRemove = keyData.slice(0, keyData.length - MAX_PAGES);
  1408. for (const item of keysToRemove) {
  1409. await GM.deleteValue(item.key);
  1410. }
  1411. }
  1412. }
  1413.  
  1414. async function addUnreadLine() {
  1415. if (window.location.hash && window.location.hash.length > 1) {
  1416. return;
  1417. }
  1418.  
  1419. const savedData = await GM.getValue(
  1420. `8chanSS_scrollPosition_${currentPage}`,
  1421. null
  1422. );
  1423.  
  1424. if (savedData) {
  1425. let position;
  1426. try {
  1427. const data = JSON.parse(savedData);
  1428. position = data.position;
  1429. await GM.setValue(
  1430. `8chanSS_scrollPosition_${currentPage}`,
  1431. JSON.stringify({
  1432. position: position,
  1433. timestamp: Date.now(),
  1434. })
  1435. );
  1436. } catch (e) {
  1437. return;
  1438. }
  1439.  
  1440. if (!isNaN(position)) {
  1441. window.scrollTo(0, position);
  1442. setTimeout(addUnreadLineAtViewportCenter, 100);
  1443. }
  1444. }
  1445. }
  1446. function addUnreadLineAtViewportCenter() {
  1447. const divPosts = document.querySelector(".divPosts");
  1448. if (!divPosts) return;
  1449. const centerX = window.innerWidth / 2;
  1450. const centerY = window.innerHeight / 2;
  1451. let el = document.elementFromPoint(centerX, centerY);
  1452. while (el && el !== divPosts && (!el.classList || !el.classList.contains("postCell"))) {
  1453. el = el.parentElement;
  1454. }
  1455. if (!el || el === divPosts || !el.id) return;
  1456. if (el.parentElement !== divPosts) return;
  1457. const oldMarker = document.getElementById("unread-line");
  1458. if (oldMarker && oldMarker.parentNode) {
  1459. oldMarker.parentNode.removeChild(oldMarker);
  1460. }
  1461. const marker = document.createElement("hr");
  1462. marker.id = "unread-line";
  1463. if (el.nextSibling) {
  1464. divPosts.insertBefore(marker, el.nextSibling);
  1465. } else {
  1466. divPosts.appendChild(marker);
  1467. }
  1468. }
  1469. window.addEventListener("beforeunload", () => {
  1470. saveScrollPosition();
  1471. });
  1472. window.addEventListener("load", async () => {
  1473. await addUnreadLine();
  1474. });
  1475. await addUnreadLine();
  1476. }
  1477. featureSaveScroll();
  1478. function featureDeleteNameCheckbox() {
  1479. const checkbox = document.createElement("input");
  1480. checkbox.type = "checkbox";
  1481. checkbox.id = "saveNameCheckbox";
  1482. checkbox.classList.add("postingCheckbox");
  1483. const label = document.createElement("label");
  1484. label.htmlFor = "saveNameCheckbox";
  1485. label.textContent = "Delete Name";
  1486. label.title = "Delete Name on refresh";
  1487. const alwaysUseBypassCheckbox = document.getElementById("qralwaysUseBypassCheckBox");
  1488. if (alwaysUseBypassCheckbox) {
  1489. alwaysUseBypassCheckbox.parentNode.insertBefore(checkbox, alwaysUseBypassCheckbox);
  1490. alwaysUseBypassCheckbox.parentNode.insertBefore(label, checkbox.nextSibling);
  1491. const savedCheckboxState = localStorage.getItem("8chanSS_deleteNameCheckbox") === "true";
  1492. checkbox.checked = savedCheckboxState;
  1493.  
  1494. const nameInput = document.getElementById("qrname");
  1495. if (nameInput) {
  1496. if (checkbox.checked) {
  1497. nameInput.value = "";
  1498. localStorage.removeItem("name");
  1499. }
  1500. checkbox.addEventListener("change", function () {
  1501. localStorage.setItem("8chanSS_deleteNameCheckbox", checkbox.checked);
  1502. });
  1503. }
  1504. }
  1505. }
  1506. function featureBeepOnYou() {
  1507. const beep = new Audio(
  1508. "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"
  1509. );
  1510. const originalTitle = document.title;
  1511. let isNotifying = false;
  1512. const observer = new MutationObserver((mutations) => {
  1513. mutations.forEach((mutation) => {
  1514. mutation.addedNodes.forEach(async (node) => {
  1515. if (
  1516. node.nodeType === 1 &&
  1517. node.querySelector &&
  1518. node.querySelector("a.quoteLink.you")
  1519. ) {
  1520. if (await getSetting("beepOnYou")) {
  1521. playBeep();
  1522. }
  1523. if (await getSetting("notifyOnYou")) {
  1524. featureNotifyOnYou();
  1525. }
  1526. }
  1527. });
  1528. });
  1529. });
  1530.  
  1531. observer.observe(document.body, { childList: true, subtree: true });
  1532. function playBeep() {
  1533. if (beep.paused) {
  1534. beep.play().catch((e) => console.warn("Beep failed:", e));
  1535. } else {
  1536. beep.addEventListener("ended", () => beep.play(), { once: true });
  1537. }
  1538. }
  1539. function featureNotifyOnYou() {
  1540. if (!window.originalTitle) {
  1541. window.originalTitle = document.title;
  1542. }
  1543. if (!window.isNotifying && !document.hasFocus()) {
  1544. window.isNotifying = true;
  1545. document.title = "(!) " + window.originalTitle;
  1546. if (!window.notifyFocusListenerAdded) {
  1547. window.addEventListener("focus", () => {
  1548. if (window.isNotifying) {
  1549. document.title = window.originalTitle;
  1550. window.isNotifying = false;
  1551. }
  1552. });
  1553. window.notifyFocusListenerAdded = true;
  1554. }
  1555. }
  1556. }
  1557. function addNotificationToTitle() {
  1558. if (!isNotifying && !document.hasFocus()) {
  1559. isNotifying = true;
  1560. document.title = "(!) " + originalTitle;
  1561. }
  1562. }
  1563. window.addEventListener("focus", () => {
  1564. if (isNotifying) {
  1565. document.title = originalTitle;
  1566. isNotifying = false;
  1567. }
  1568. });
  1569. }
  1570.  
  1571. if (await getSetting("enableScrollSave")) {
  1572. featureSaveScroll();
  1573. }
  1574. if (await getSetting("watchThreadOnReply")) {
  1575. featureWatchThreadOnReply();
  1576. }
  1577. if (await getSetting("blurSpoilers")) {
  1578. featureBlurSpoilers();
  1579. }
  1580. if (await getSetting("enableHeaderCatalogLinks")) {
  1581. featureHeaderCatalogLinks();
  1582. }
  1583. if (await getSetting("deleteSavedName")) {
  1584. featureDeleteNameCheckbox();
  1585. }
  1586. if (await getSetting("enableScrollArrows")) {
  1587. featureScrollArrows();
  1588. }
  1589. if ((await getSetting("beepOnYou")) || (await getSetting("notifyOnYou"))) {
  1590. featureBeepOnYou();
  1591. }
  1592. if (await getSetting("alwaysShowTW")) {
  1593. featureAlwaysShowTW();
  1594. }
  1595. const isCatalogPage = /\/catalog\.html$/.test(
  1596. window.location.pathname.toLowerCase()
  1597. );
  1598. if (
  1599. (isCatalogPage && (await getSetting("enableCatalogImageHover"))) ||
  1600. (!isCatalogPage && (await getSetting("enableThreadImageHover")))
  1601. ) {
  1602. featureImageHover();
  1603. }
  1604. document.addEventListener("keydown", async function (event) {
  1605. if (event.ctrlKey && event.key === "F1") {
  1606. event.preventDefault();
  1607. let menu =
  1608. document.getElementById("8chanSS-menu") ||
  1609. (await createSettingsMenu());
  1610. menu.style.display =
  1611. menu.style.display === "none" || menu.style.display === ""
  1612. ? "block"
  1613. : "none";
  1614. }
  1615. });
  1616. async function submitWithCtrlEnter(event) {
  1617. if (event.ctrlKey && event.key === "Enter") {
  1618. event.preventDefault();
  1619. const submitButton = document.getElementById("qrbutton");
  1620. if (submitButton) {
  1621. submitButton.click();
  1622. if (await getSetting("watchThreadOnReply")) {
  1623. setTimeout(() => {
  1624. const btn = document.querySelector(".watchButton");
  1625. if (btn && !btn.classList.contains("watched-active")) {
  1626. btn.click();
  1627. setTimeout(() => {
  1628. btn.classList.add("watched-active");
  1629. }, 100);
  1630. }
  1631. }, 500);
  1632. }
  1633. }
  1634. }
  1635. }
  1636. const replyTextarea = document.getElementById("qrbody");
  1637. if (replyTextarea) {
  1638. replyTextarea.addEventListener("keydown", submitWithCtrlEnter);
  1639. }
  1640. function toggleQR(event) {
  1641. if (event.ctrlKey && (event.key === "q" || event.key === "Q")) {
  1642. const hiddenDiv = document.getElementById("quick-reply");
  1643. if (
  1644. hiddenDiv.style.display === "none" ||
  1645. hiddenDiv.style.display === ""
  1646. ) {
  1647. hiddenDiv.style.display = "block";
  1648. setTimeout(() => {
  1649. const textarea = document.getElementById("qrbody");
  1650. if (textarea) {
  1651. textarea.focus();
  1652. }
  1653. }, 50);
  1654. } else {
  1655. hiddenDiv.style.display = "none";
  1656. }
  1657. }
  1658. }
  1659. document.addEventListener("keydown", toggleQR);
  1660. function clearTextarea(event) {
  1661. if (event.key === "Escape") {
  1662. const textarea = document.getElementById("qrbody");
  1663. if (textarea) {
  1664. textarea.value = "";
  1665. }
  1666. const quickReply = document.getElementById("quick-reply");
  1667. if (quickReply) {
  1668. quickReply.style.display = "none";
  1669. }
  1670. }
  1671. }
  1672. document.addEventListener("keydown", clearTextarea);
  1673. const bbCodeCombinations = new Map([
  1674. ["s", ["[spoiler]", "[/spoiler]"]],
  1675. ["b", ["'''", "'''"]],
  1676. ["u", ["__", "__"]],
  1677. ["i", ["''", "''"]],
  1678. ["d", ["[doom]", "[/doom]"]],
  1679. ["m", ["[moe]", "[/moe]"]],
  1680. ["c", ["[code]", "[/code]"]],
  1681. ]);
  1682.  
  1683. function replyKeyboardShortcuts(ev) {
  1684. const key = ev.key.toLowerCase();
  1685. if (
  1686. key === "c" &&
  1687. ev.altKey &&
  1688. !ev.ctrlKey &&
  1689. bbCodeCombinations.has(key)
  1690. ) {
  1691. ev.preventDefault();
  1692. const textBox = ev.target;
  1693. const [openTag, closeTag] = bbCodeCombinations.get(key);
  1694. const { selectionStart, selectionEnd, value } = textBox;
  1695. if (selectionStart === selectionEnd) {
  1696. const before = value.slice(0, selectionStart);
  1697. const after = value.slice(selectionEnd);
  1698. const newCursor = selectionStart + openTag.length;
  1699. textBox.value = before + openTag + closeTag + after;
  1700. textBox.selectionStart = textBox.selectionEnd = newCursor;
  1701. } else {
  1702. const before = value.slice(0, selectionStart);
  1703. const selected = value.slice(selectionStart, selectionEnd);
  1704. const after = value.slice(selectionEnd);
  1705. textBox.value = before + openTag + selected + closeTag + after;
  1706. textBox.selectionStart = selectionStart + openTag.length;
  1707. textBox.selectionEnd = selectionEnd + openTag.length;
  1708. }
  1709. return;
  1710. }
  1711. if (
  1712. ev.ctrlKey &&
  1713. !ev.altKey &&
  1714. bbCodeCombinations.has(key) &&
  1715. key !== "c"
  1716. ) {
  1717. ev.preventDefault();
  1718. const textBox = ev.target;
  1719. const [openTag, closeTag] = bbCodeCombinations.get(key);
  1720. const { selectionStart, selectionEnd, value } = textBox;
  1721. if (selectionStart === selectionEnd) {
  1722. const before = value.slice(0, selectionStart);
  1723. const after = value.slice(selectionEnd);
  1724. const newCursor = selectionStart + openTag.length;
  1725. textBox.value = before + openTag + closeTag + after;
  1726. textBox.selectionStart = textBox.selectionEnd = newCursor;
  1727. } else {
  1728. const before = value.slice(0, selectionStart);
  1729. const selected = value.slice(selectionStart, selectionEnd);
  1730. const after = value.slice(selectionEnd);
  1731. textBox.value = before + openTag + selected + closeTag + after;
  1732. textBox.selectionStart = selectionStart + openTag.length;
  1733. textBox.selectionEnd = selectionEnd + openTag.length;
  1734. }
  1735. return;
  1736. }
  1737. }
  1738. document
  1739. .getElementById("qrbody")
  1740. ?.addEventListener("keydown", replyKeyboardShortcuts);
  1741. });