8chan Style Script

Script to style 8chan

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

  1. // ==UserScript==
  2. // @name 8chan Style Script
  3. // @namespace 8chanSS
  4. // @match *://8chan.moe/*
  5. // @match *://8chan.se/*
  6. // @exclude *://8chan.moe/login.html
  7. // @exclude *://8chan.se/login.html
  8. // @grant GM.getValue
  9. // @grant GM.setValue
  10. // @grant GM.deleteValue
  11. // @grant GM.listValues
  12. // @version 1.24
  13. // @author OtakuDude
  14. // @run-at document-idle
  15. // @description Script to style 8chan
  16. // @license MIT
  17. // ==/UserScript==
  18. (async function () {
  19. /**
  20. * Temporary: Remove all old 8chanSS_ keys from localStorage to not interfere with GM storage
  21. */
  22. function cleanupOld8chanSSLocalStorage() {
  23. try {
  24. const keysToRemove = [];
  25. for (let i = 0; i < localStorage.length; i++) {
  26. const key = localStorage.key(i);
  27. if (
  28. key &&
  29. (key.startsWith("8chanSS_") || key.startsWith("scrollPosition_"))
  30. ) {
  31. keysToRemove.push(key);
  32. }
  33. }
  34. keysToRemove.forEach((key) => localStorage.removeItem(key));
  35. } catch (e) {
  36. // Some browsers/extensions may restrict localStorage access
  37. console.warn("8chanSS: Could not clean up old localStorage keys:", e);
  38. }
  39. }
  40.  
  41. // Call immediately at script start
  42. cleanupOld8chanSSLocalStorage();
  43.  
  44. // --- Settings ---
  45. const scriptSettings = {
  46. // Organize settings by category
  47. site: {
  48. enableHeaderCatalogLinks: {
  49. label: "Header Catalog Links",
  50. default: true,
  51. subOptions: {
  52. openInNewTab: { label: "Always open in new tab", default: false },
  53. },
  54. },
  55. enableBottomHeader: { label: "Bottom Header", default: false },
  56. enableScrollSave: { label: "Save Scroll Position", default: true },
  57. enableScrollArrows: { label: "Show Up/Down Arrows", default: false },
  58. alwaysShowTW: { label: "Always Show Thread Watcher", default: false },
  59. hoverVideoVolume: {
  60. label: "Hover Video Volume (0-100%)",
  61. default: 50,
  62. type: "number",
  63. min: 0,
  64. max: 100,
  65. },
  66. },
  67. threads: {
  68. beepOnYou: { label: "Beep on (You)", default: false },
  69. notifyOnYou: { label: "Notify when (You) (!)", default: true },
  70. blurSpoilers: {
  71. label: "Blur Spoilers",
  72. default: false,
  73. subOptions: {
  74. removeSpoilers: { label: "Remove Spoilers", default: false },
  75. },
  76. },
  77. enableSaveName: { label: "Save Name Checkbox", default: true },
  78. enableThreadImageHover: {
  79. label: "Thread Image Hover",
  80. default: true,
  81. },
  82. },
  83. catalog: {
  84. enableCatalogImageHover: {
  85. label: "Catalog Image Hover",
  86. default: true,
  87. },
  88. },
  89. styling: {
  90. enableStickyQR: {
  91. label: "Enable Sticky Quick Reply",
  92. default: false,
  93. },
  94. enableFitReplies: { label: "Fit Replies", default: false },
  95. enableSidebar: { label: "Enable Sidebar", default: false },
  96. hideAnnouncement: { label: "Hide Announcement", default: false },
  97. hidePanelMessage: { label: "Hide Panel Message", default: false },
  98. hidePostingForm: {
  99. label: "Hide Posting Form",
  100. default: false,
  101. subOptions: {
  102. showCatalogForm: {
  103. label: "Don't Hide in Catalog",
  104. default: false,
  105. },
  106. },
  107. },
  108. hideBanner: { label: "Hide Board Banners", default: false },
  109. },
  110. };
  111.  
  112. // Flatten settings for backward compatibility with existing functions
  113. const flatSettings = {};
  114. function flattenSettings() {
  115. Object.keys(scriptSettings).forEach((category) => {
  116. Object.keys(scriptSettings[category]).forEach((key) => {
  117. flatSettings[key] = scriptSettings[category][key];
  118. // Also flatten any sub-options
  119. if (scriptSettings[category][key].subOptions) {
  120. Object.keys(scriptSettings[category][key].subOptions).forEach(
  121. (subKey) => {
  122. const fullKey = `${key}_${subKey}`;
  123. flatSettings[fullKey] =
  124. scriptSettings[category][key].subOptions[subKey];
  125. }
  126. );
  127. }
  128. });
  129. });
  130. }
  131. flattenSettings();
  132.  
  133. // --- GM storage wrappers ---
  134. async function getSetting(key) {
  135. if (!flatSettings[key]) {
  136. console.warn(`Setting key not found: ${key}`);
  137. return false;
  138. }
  139. let val = await GM.getValue("8chanSS_" + key, null);
  140. if (val === null) return flatSettings[key].default;
  141. if (flatSettings[key].type === "number") return Number(val);
  142. return val === "true";
  143. }
  144.  
  145. async function setSetting(key, value) {
  146. // Always store as string for consistency
  147. await GM.setValue("8chanSS_" + key, String(value));
  148. }
  149.  
  150. // --- Menu Icon ---
  151. const themeSelector = document.getElementById("themesBefore");
  152. let link = null;
  153. let bracketSpan = null;
  154. if (themeSelector) {
  155. bracketSpan = document.createElement("span");
  156. bracketSpan.textContent = "] [ ";
  157. link = document.createElement("a");
  158. link.id = "8chanSS-icon";
  159. link.href = "#";
  160. link.textContent = "8chanSS";
  161. link.style.fontWeight = "bold";
  162.  
  163. themeSelector.parentNode.insertBefore(
  164. bracketSpan,
  165. themeSelector.nextSibling
  166. );
  167. themeSelector.parentNode.insertBefore(link, bracketSpan.nextSibling);
  168. }
  169.  
  170. // --- Shortcuts tab ---
  171. function createShortcutsTab() {
  172. const container = document.createElement("div");
  173. // Title
  174. const title = document.createElement("h3");
  175. title.textContent = "Keyboard Shortcuts";
  176. title.style.margin = "0 0 15px 0";
  177. title.style.fontSize = "16px";
  178. container.appendChild(title);
  179. // Shortcuts table
  180. const table = document.createElement("table");
  181. table.style.width = "100%";
  182. table.style.borderCollapse = "collapse";
  183. // Table styles
  184. const tableStyles = {
  185. th: {
  186. textAlign: "left",
  187. padding: "8px 5px",
  188. borderBottom: "1px solid #444",
  189. fontSize: "14px",
  190. fontWeight: "bold",
  191. },
  192. td: {
  193. padding: "8px 5px",
  194. borderBottom: "1px solid #333",
  195. fontSize: "13px",
  196. },
  197. kbd: {
  198. background: "#333",
  199. border: "1px solid #555",
  200. borderRadius: "3px",
  201. padding: "2px 5px",
  202. fontSize: "12px",
  203. fontFamily: "monospace",
  204. },
  205. };
  206.  
  207. // Create header row
  208. const headerRow = document.createElement("tr");
  209. const shortcutHeader = document.createElement("th");
  210. shortcutHeader.textContent = "Shortcut";
  211. Object.assign(shortcutHeader.style, tableStyles.th);
  212. headerRow.appendChild(shortcutHeader);
  213.  
  214. const actionHeader = document.createElement("th");
  215. actionHeader.textContent = "Action";
  216. Object.assign(actionHeader.style, tableStyles.th);
  217. headerRow.appendChild(actionHeader);
  218.  
  219. table.appendChild(headerRow);
  220.  
  221. // Shortcut data
  222. const shortcuts = [
  223. { keys: ["Ctrl", "F1"], action: "Open 8chanSS settings" },
  224. { keys: ["Ctrl", "Q"], action: "Toggle Quick Reply" },
  225. { keys: ["Ctrl", "Enter"], action: "Submit post" },
  226. { keys: ["Escape"], action: "Clear textarea and hide Quick Reply" },
  227. { keys: ["Ctrl", "B"], action: "Bold text" },
  228. { keys: ["Ctrl", "I"], action: "Italic text" },
  229. { keys: ["Ctrl", "U"], action: "Underline text" },
  230. { keys: ["Ctrl", "S"], action: "Spoiler text" },
  231. { keys: ["Ctrl", "D"], action: "Doom text" },
  232. { keys: ["Ctrl", "M"], action: "Moe text" },
  233. { keys: ["Alt", "C"], action: "Code block" },
  234. ];
  235.  
  236. // Create rows for each shortcut
  237. shortcuts.forEach((shortcut) => {
  238. const row = document.createElement("tr");
  239.  
  240. // Shortcut cell
  241. const shortcutCell = document.createElement("td");
  242. Object.assign(shortcutCell.style, tableStyles.td);
  243.  
  244. // Create kbd elements for each key
  245. shortcut.keys.forEach((key, index) => {
  246. const kbd = document.createElement("kbd");
  247. kbd.textContent = key;
  248. Object.assign(kbd.style, tableStyles.kbd);
  249. shortcutCell.appendChild(kbd);
  250.  
  251. // Add + between keys
  252. if (index < shortcut.keys.length - 1) {
  253. const plus = document.createTextNode(" + ");
  254. shortcutCell.appendChild(plus);
  255. }
  256. });
  257.  
  258. row.appendChild(shortcutCell);
  259.  
  260. // Action cell
  261. const actionCell = document.createElement("td");
  262. actionCell.textContent = shortcut.action;
  263. Object.assign(actionCell.style, tableStyles.td);
  264. row.appendChild(actionCell);
  265.  
  266. table.appendChild(row);
  267. });
  268.  
  269. container.appendChild(table);
  270.  
  271. // Add note about BBCode shortcuts
  272. const note = document.createElement("p");
  273. note.textContent =
  274. "Text formatting shortcuts work when text is selected or when inserting at cursor position.";
  275. note.style.fontSize = "12px";
  276. note.style.marginTop = "15px";
  277. note.style.opacity = "0.7";
  278. note.style.fontStyle = "italic";
  279. container.appendChild(note);
  280.  
  281. return container;
  282. }
  283.  
  284. // --- Floating Settings Menu with Tabs ---
  285. async function createSettingsMenu() {
  286. let menu = document.getElementById("8chanSS-menu");
  287. if (menu) return menu;
  288. menu = document.createElement("div");
  289. menu.id = "8chanSS-menu";
  290. menu.style.position = "fixed";
  291. menu.style.top = "80px";
  292. menu.style.left = "30px";
  293. menu.style.zIndex = 99999;
  294. menu.style.background = "#222";
  295. menu.style.color = "#fff";
  296. menu.style.padding = "0";
  297. menu.style.borderRadius = "8px";
  298. menu.style.boxShadow = "0 4px 16px rgba(0,0,0,0.25)";
  299. menu.style.display = "none";
  300. menu.style.minWidth = "220px";
  301. menu.style.width = "100%";
  302. menu.style.maxWidth = "365px";
  303. menu.style.fontFamily = "sans-serif";
  304. menu.style.userSelect = "none";
  305.  
  306. // Draggable
  307. let isDragging = false,
  308. dragOffsetX = 0,
  309. dragOffsetY = 0;
  310. const header = document.createElement("div");
  311. header.style.display = "flex";
  312. header.style.justifyContent = "space-between";
  313. header.style.alignItems = "center";
  314. header.style.marginBottom = "0";
  315. header.style.cursor = "move";
  316. header.style.background = "#333";
  317. header.style.padding = "5px 18px 5px";
  318. header.style.borderTopLeftRadius = "8px";
  319. header.style.borderTopRightRadius = "8px";
  320. header.addEventListener("mousedown", function (e) {
  321. isDragging = true;
  322. const rect = menu.getBoundingClientRect();
  323. dragOffsetX = e.clientX - rect.left;
  324. dragOffsetY = e.clientY - rect.top;
  325. document.body.style.userSelect = "none";
  326. });
  327. document.addEventListener("mousemove", function (e) {
  328. if (!isDragging) return;
  329. let newLeft = e.clientX - dragOffsetX;
  330. let newTop = e.clientY - dragOffsetY;
  331. const menuRect = menu.getBoundingClientRect();
  332. const menuWidth = menuRect.width;
  333. const menuHeight = menuRect.height;
  334. const viewportWidth = window.innerWidth;
  335. const viewportHeight = window.innerHeight;
  336. newLeft = Math.max(0, Math.min(newLeft, viewportWidth - menuWidth));
  337. newTop = Math.max(0, Math.min(newTop, viewportHeight - menuHeight));
  338. menu.style.left = newLeft + "px";
  339. menu.style.top = newTop + "px";
  340. menu.style.right = "auto";
  341. });
  342. document.addEventListener("mouseup", function () {
  343. isDragging = false;
  344. document.body.style.userSelect = "";
  345. });
  346.  
  347. // Title and close button
  348. const title = document.createElement("span");
  349. title.textContent = "8chanSS Settings";
  350. title.style.fontWeight = "bold";
  351. header.appendChild(title);
  352.  
  353. const closeBtn = document.createElement("button");
  354. closeBtn.textContent = "✕";
  355. closeBtn.style.background = "none";
  356. closeBtn.style.border = "none";
  357. closeBtn.style.color = "#fff";
  358. closeBtn.style.fontSize = "18px";
  359. closeBtn.style.cursor = "pointer";
  360. closeBtn.style.marginLeft = "10px";
  361. closeBtn.addEventListener("click", () => {
  362. menu.style.display = "none";
  363. });
  364. header.appendChild(closeBtn);
  365.  
  366. menu.appendChild(header);
  367.  
  368. // Tab navigation
  369. const tabNav = document.createElement("div");
  370. tabNav.style.display = "flex";
  371. tabNav.style.borderBottom = "1px solid #444";
  372. tabNav.style.background = "#2a2a2a";
  373.  
  374. // Tab content container
  375. const tabContent = document.createElement("div");
  376. tabContent.style.padding = "15px 18px";
  377. tabContent.style.maxHeight = "60vh";
  378. tabContent.style.overflowY = "auto";
  379.  
  380. // Store current (unsaved) values
  381. const tempSettings = {};
  382. await Promise.all(
  383. Object.keys(flatSettings).map(async (key) => {
  384. tempSettings[key] = await getSetting(key);
  385. })
  386. );
  387.  
  388. // Create tabs
  389. const tabs = {
  390. site: {
  391. label: "Site",
  392. content: createTabContent("site", tempSettings),
  393. },
  394. threads: {
  395. label: "Threads",
  396. content: createTabContent("threads", tempSettings),
  397. },
  398. catalog: {
  399. label: "Catalog",
  400. content: createTabContent("catalog", tempSettings),
  401. },
  402. styling: {
  403. label: "Style",
  404. content: createTabContent("styling", tempSettings),
  405. },
  406. shortcuts: {
  407. label: "⌨️",
  408. content: createShortcutsTab(),
  409. },
  410. };
  411.  
  412. // Create tab buttons
  413. Object.keys(tabs).forEach((tabId, index, arr) => {
  414. const tab = tabs[tabId];
  415. const tabButton = document.createElement("button");
  416. tabButton.textContent = tab.label;
  417. tabButton.dataset.tab = tabId;
  418. tabButton.style.background = index === 0 ? "#333" : "transparent";
  419. tabButton.style.border = "none";
  420. tabButton.style.borderRight = "1px solid #444";
  421. tabButton.style.color = "#fff";
  422. tabButton.style.padding = "8px 15px";
  423. tabButton.style.margin = "5px 0 0 0";
  424. tabButton.style.cursor = "pointer";
  425. tabButton.style.flex = "1";
  426. tabButton.style.fontSize = "14px";
  427. tabButton.style.transition = "background 0.2s";
  428.  
  429. // Add rounded corners and margin to the first and last tab
  430. if (index === 0) {
  431. tabButton.style.borderTopLeftRadius = "8px";
  432. tabButton.style.margin = "5px 0 0 5px";
  433. }
  434. if (index === arr.length - 1) {
  435. tabButton.style.borderTopRightRadius = "8px";
  436. tabButton.style.margin = "5px 5px 0 0";
  437. tabButton.style.borderRight = "none"; // Remove border on last tab
  438. }
  439.  
  440. tabButton.addEventListener("click", () => {
  441. // Hide all tab contents
  442. Object.values(tabs).forEach((t) => {
  443. t.content.style.display = "none";
  444. });
  445.  
  446. // Show selected tab content
  447. tab.content.style.display = "block";
  448.  
  449. // Update active tab button
  450. tabNav.querySelectorAll("button").forEach((btn) => {
  451. btn.style.background = "transparent";
  452. });
  453. tabButton.style.background = "#333";
  454. });
  455.  
  456. tabNav.appendChild(tabButton);
  457. });
  458.  
  459. menu.appendChild(tabNav);
  460.  
  461. // Add all tab contents to the container
  462. Object.values(tabs).forEach((tab, index) => {
  463. tab.content.style.display = index === 0 ? "block" : "none";
  464. tabContent.appendChild(tab.content);
  465. });
  466.  
  467. menu.appendChild(tabContent);
  468.  
  469. // Button container for Save and Reset buttons
  470. const buttonContainer = document.createElement("div");
  471. buttonContainer.style.display = "flex";
  472. buttonContainer.style.gap = "10px";
  473. buttonContainer.style.padding = "0 18px 15px";
  474.  
  475. // Save Button
  476. const saveBtn = document.createElement("button");
  477. saveBtn.textContent = "Save";
  478. saveBtn.style.background = "#4caf50";
  479. saveBtn.style.color = "#fff";
  480. saveBtn.style.border = "none";
  481. saveBtn.style.borderRadius = "4px";
  482. saveBtn.style.padding = "8px 18px";
  483. saveBtn.style.fontSize = "15px";
  484. saveBtn.style.cursor = "pointer";
  485. saveBtn.style.flex = "1";
  486. saveBtn.addEventListener("click", async function () {
  487. for (const key of Object.keys(tempSettings)) {
  488. await setSetting(key, tempSettings[key]);
  489. }
  490. saveBtn.textContent = "Saved!";
  491. setTimeout(() => {
  492. saveBtn.textContent = "Save";
  493. }, 900);
  494. setTimeout(() => {
  495. window.location.reload();
  496. }, 400);
  497. });
  498. buttonContainer.appendChild(saveBtn);
  499.  
  500. // Reset Button
  501. const resetBtn = document.createElement("button");
  502. resetBtn.textContent = "Reset";
  503. resetBtn.style.background = "#dd3333";
  504. resetBtn.style.color = "#fff";
  505. resetBtn.style.border = "none";
  506. resetBtn.style.borderRadius = "4px";
  507. resetBtn.style.padding = "8px 18px";
  508. resetBtn.style.fontSize = "15px";
  509. resetBtn.style.cursor = "pointer";
  510. resetBtn.style.flex = "1";
  511. resetBtn.addEventListener("click", async function () {
  512. if (confirm("Reset all 8chanSS settings to defaults?")) {
  513. // Remove all 8chanSS_ GM values
  514. const keys = await GM.listValues();
  515. for (const key of keys) {
  516. if (key.startsWith("8chanSS_")) {
  517. await GM.deleteValue(key);
  518. }
  519. }
  520. resetBtn.textContent = "Reset!";
  521. setTimeout(() => {
  522. resetBtn.textContent = "Reset";
  523. }, 900);
  524. setTimeout(() => {
  525. window.location.reload();
  526. }, 400);
  527. }
  528. });
  529. buttonContainer.appendChild(resetBtn);
  530.  
  531. menu.appendChild(buttonContainer);
  532.  
  533. // Info
  534. const info = document.createElement("div");
  535. info.style.fontSize = "11px";
  536. info.style.padding = "0 18px 12px";
  537. info.style.opacity = "0.7";
  538. info.style.textAlign = "center";
  539. info.textContent = "Press Save to apply changes. Page will reload.";
  540. menu.appendChild(info);
  541.  
  542. document.body.appendChild(menu);
  543. return menu;
  544. }
  545.  
  546. // Helper function to create tab content
  547. function createTabContent(category, tempSettings) {
  548. const container = document.createElement("div");
  549. const categorySettings = scriptSettings[category];
  550.  
  551. Object.keys(categorySettings).forEach((key) => {
  552. const setting = categorySettings[key];
  553.  
  554. // Parent row: flex for checkbox, label, chevron
  555. const parentRow = document.createElement("div");
  556. parentRow.style.display = "flex";
  557. parentRow.style.alignItems = "center";
  558. parentRow.style.marginBottom = "0px";
  559.  
  560. // Special case: hoverVideoVolume slider
  561. if (key === "hoverVideoVolume" && setting.type === "number") {
  562. const label = document.createElement("label");
  563. label.htmlFor = "setting_" + key;
  564. label.textContent = setting.label + ": ";
  565. label.style.flex = "1";
  566.  
  567. const sliderContainer = document.createElement("div");
  568. sliderContainer.style.display = "flex";
  569. sliderContainer.style.alignItems = "center";
  570. sliderContainer.style.flex = "1";
  571.  
  572. const slider = document.createElement("input");
  573. slider.type = "range";
  574. slider.id = "setting_" + key;
  575. slider.min = setting.min;
  576. slider.max = setting.max;
  577. slider.value = Number(tempSettings[key]);
  578. slider.style.flex = "unset";
  579. slider.style.width = "100px";
  580. slider.style.marginRight = "10px";
  581.  
  582. const valueLabel = document.createElement("span");
  583. valueLabel.textContent = slider.value + "%";
  584. valueLabel.style.minWidth = "40px";
  585. valueLabel.style.textAlign = "right";
  586.  
  587. slider.addEventListener("input", function () {
  588. let val = Number(slider.value);
  589. if (isNaN(val)) val = setting.default;
  590. val = Math.max(setting.min, Math.min(setting.max, val));
  591. slider.value = val;
  592. tempSettings[key] = val;
  593. valueLabel.textContent = val + "%";
  594. });
  595.  
  596. sliderContainer.appendChild(slider);
  597. sliderContainer.appendChild(valueLabel);
  598.  
  599. parentRow.appendChild(label);
  600. parentRow.appendChild(sliderContainer);
  601.  
  602. // Wrapper for parent row and sub-options
  603. const wrapper = document.createElement("div");
  604. wrapper.style.marginBottom = "10px";
  605. wrapper.appendChild(parentRow);
  606. container.appendChild(wrapper);
  607. return; // Skip the rest for this key
  608. }
  609.  
  610. // Checkbox for boolean settings
  611. const checkbox = document.createElement("input");
  612. checkbox.type = "checkbox";
  613. checkbox.id = "setting_" + key;
  614. checkbox.checked =
  615. tempSettings[key] === true || tempSettings[key] === "true";
  616. checkbox.style.marginRight = "8px";
  617.  
  618. // Label
  619. const label = document.createElement("label");
  620. label.htmlFor = checkbox.id;
  621. label.textContent = setting.label;
  622. label.style.flex = "1";
  623.  
  624. // Chevron for subOptions
  625. let chevron = null;
  626. let subOptionsContainer = null;
  627. if (setting.subOptions) {
  628. chevron = document.createElement("span");
  629. chevron.className = "ss-chevron";
  630. chevron.innerHTML = "&#9654;"; // Right-pointing triangle
  631. chevron.style.display = "inline-block";
  632. chevron.style.transition = "transform 0.2s";
  633. chevron.style.marginLeft = "6px";
  634. chevron.style.fontSize = "12px";
  635. chevron.style.userSelect = "none";
  636. chevron.style.transform = checkbox.checked
  637. ? "rotate(90deg)"
  638. : "rotate(0deg)";
  639. }
  640.  
  641. // Checkbox change handler
  642. checkbox.addEventListener("change", function () {
  643. tempSettings[key] = checkbox.checked;
  644. if (setting.subOptions && subOptionsContainer) {
  645. subOptionsContainer.style.display = checkbox.checked
  646. ? "block"
  647. : "none";
  648. if (chevron) {
  649. chevron.style.transform = checkbox.checked
  650. ? "rotate(90deg)"
  651. : "rotate(0deg)";
  652. }
  653. }
  654. });
  655.  
  656. parentRow.appendChild(checkbox);
  657. parentRow.appendChild(label);
  658. if (chevron) parentRow.appendChild(chevron);
  659.  
  660. // Wrapper for parent row and sub-options
  661. const wrapper = document.createElement("div");
  662. wrapper.style.marginBottom = "10px";
  663.  
  664. wrapper.appendChild(parentRow);
  665.  
  666. // Handle sub-options if any exist
  667. if (setting.subOptions) {
  668. subOptionsContainer = document.createElement("div");
  669. subOptionsContainer.style.marginLeft = "25px";
  670. subOptionsContainer.style.marginTop = "5px";
  671. subOptionsContainer.style.display = checkbox.checked ? "block" : "none";
  672.  
  673. Object.keys(setting.subOptions).forEach((subKey) => {
  674. const subSetting = setting.subOptions[subKey];
  675. const fullKey = `${key}_${subKey}`;
  676.  
  677. const subWrapper = document.createElement("div");
  678. subWrapper.style.marginBottom = "5px";
  679.  
  680. const subCheckbox = document.createElement("input");
  681. subCheckbox.type = "checkbox";
  682. subCheckbox.id = "setting_" + fullKey;
  683. subCheckbox.checked = tempSettings[fullKey];
  684. subCheckbox.style.marginRight = "8px";
  685.  
  686. subCheckbox.addEventListener("change", function () {
  687. tempSettings[fullKey] = subCheckbox.checked;
  688. });
  689.  
  690. const subLabel = document.createElement("label");
  691. subLabel.htmlFor = subCheckbox.id;
  692. subLabel.textContent = subSetting.label;
  693.  
  694. subWrapper.appendChild(subCheckbox);
  695. subWrapper.appendChild(subLabel);
  696. subOptionsContainer.appendChild(subWrapper);
  697. });
  698.  
  699. wrapper.appendChild(subOptionsContainer);
  700. }
  701.  
  702. container.appendChild(wrapper);
  703. });
  704.  
  705. // Add minimal CSS for chevron (only once)
  706. if (!document.getElementById("ss-chevron-style")) {
  707. const style = document.createElement("style");
  708. style.id = "ss-chevron-style";
  709. style.textContent = `
  710. .ss-chevron {
  711. transition: transform 0.2s;
  712. margin-left: 6px;
  713. font-size: 12px;
  714. display: inline-block;
  715. }
  716. `;
  717. document.head.appendChild(style);
  718. }
  719.  
  720. return container;
  721. }
  722.  
  723. // Hook up the icon to open/close the menu
  724. if (link) {
  725. let menu = await createSettingsMenu();
  726. link.style.cursor = "pointer";
  727. link.title = "Open 8chanSS settings";
  728. link.addEventListener("click", async function (e) {
  729. e.preventDefault();
  730. let menu = await createSettingsMenu();
  731. menu.style.display = menu.style.display === "none" ? "block" : "none";
  732. });
  733. }
  734.  
  735. /* --- Scroll Arrows Feature --- */
  736. function featureScrollArrows() {
  737. // Only add once
  738. if (
  739. document.getElementById("scroll-arrow-up") ||
  740. document.getElementById("scroll-arrow-down")
  741. )
  742. return;
  743.  
  744. // Up arrow
  745. const upBtn = document.createElement("button");
  746. upBtn.id = "scroll-arrow-up";
  747. upBtn.className = "scroll-arrow-btn";
  748. upBtn.title = "Scroll to top";
  749. upBtn.innerHTML = "▲";
  750. upBtn.addEventListener("click", () => {
  751. window.scrollTo({ top: 0, behavior: "smooth" });
  752. });
  753.  
  754. // Down arrow
  755. const downBtn = document.createElement("button");
  756. downBtn.id = "scroll-arrow-down";
  757. downBtn.className = "scroll-arrow-btn";
  758. downBtn.title = "Scroll to bottom";
  759. downBtn.innerHTML = "▼";
  760. downBtn.addEventListener("click", () => {
  761. const footer = document.getElementById("footer");
  762. if (footer) {
  763. footer.scrollIntoView({ behavior: "smooth", block: "end" });
  764. } else {
  765. window.scrollTo({
  766. top: document.body.scrollHeight,
  767. behavior: "smooth",
  768. });
  769. }
  770. });
  771.  
  772. document.body.appendChild(upBtn);
  773. document.body.appendChild(downBtn);
  774. }
  775.  
  776. // --- Feature: Beep on (You) ---
  777. function featureBeepOnYou() {
  778. // Beep sound (base64)
  779. const beep = new Audio(
  780. "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"
  781. );
  782.  
  783. // Store the original title
  784. const originalTitle = document.title;
  785. let isNotifying = false;
  786.  
  787. // Create MutationObserver to detect when you are quoted
  788. const observer = new MutationObserver((mutations) => {
  789. mutations.forEach((mutation) => {
  790. mutation.addedNodes.forEach(async (node) => {
  791. if (
  792. node.nodeType === 1 &&
  793. node.querySelector &&
  794. node.querySelector("a.quoteLink.you")
  795. ) {
  796. // Only play beep if the setting is enabled
  797. if (await getSetting("beepOnYou")) {
  798. playBeep();
  799. }
  800.  
  801. // Trigger notification in separate function if enabled
  802. if (await getSetting("notifyOnYou")) {
  803. featureNotifyOnYou();
  804. }
  805. }
  806. });
  807. });
  808. });
  809.  
  810. observer.observe(document.body, { childList: true, subtree: true });
  811.  
  812. // Function to play the beep sound
  813. function playBeep() {
  814. if (beep.paused) {
  815. beep.play().catch((e) => console.warn("Beep failed:", e));
  816. } else {
  817. beep.addEventListener("ended", () => beep.play(), { once: true });
  818. }
  819. }
  820. // Function to notify on (You)
  821. function featureNotifyOnYou() {
  822. // Store the original title if not already stored
  823. if (!window.originalTitle) {
  824. window.originalTitle = document.title;
  825. }
  826.  
  827. // Add notification to title if not already notifying and tab not focused
  828. if (!window.isNotifying && !document.hasFocus()) {
  829. window.isNotifying = true;
  830. document.title = "(!) " + window.originalTitle;
  831.  
  832. // Set up focus event listener if not already set
  833. if (!window.notifyFocusListenerAdded) {
  834. window.addEventListener("focus", () => {
  835. if (window.isNotifying) {
  836. document.title = window.originalTitle;
  837. window.isNotifying = false;
  838. }
  839. });
  840. window.notifyFocusListenerAdded = true;
  841. }
  842. }
  843. }
  844. // Function to add notification to the title
  845. function addNotificationToTitle() {
  846. if (!isNotifying && !document.hasFocus()) {
  847. isNotifying = true;
  848. document.title = "(!) " + originalTitle;
  849. }
  850. }
  851. // Remove notification when tab regains focus
  852. window.addEventListener("focus", () => {
  853. if (isNotifying) {
  854. document.title = originalTitle;
  855. isNotifying = false;
  856. }
  857. });
  858. }
  859.  
  860. // --- Feature: Header Catalog Links ---
  861. async function featureHeaderCatalogLinks() {
  862. async function appendCatalogToLinks() {
  863. const navboardsSpan = document.getElementById("navBoardsSpan");
  864. if (navboardsSpan) {
  865. const links = navboardsSpan.getElementsByTagName("a");
  866. const openInNewTab = await getSetting(
  867. "enableHeaderCatalogLinks_openInNewTab"
  868. );
  869.  
  870. for (let link of links) {
  871. if (link.href && !link.href.endsWith("/catalog.html")) {
  872. link.href += "/catalog.html";
  873.  
  874. // Set target="_blank" if the option is enabled
  875. if (openInNewTab) {
  876. link.target = "_blank";
  877. link.rel = "noopener noreferrer"; // Security best practice
  878. } else {
  879. link.target = "";
  880. link.rel = "";
  881. }
  882. }
  883. }
  884. }
  885. }
  886.  
  887. appendCatalogToLinks();
  888. const observer = new MutationObserver(appendCatalogToLinks);
  889. const config = { childList: true, subtree: true };
  890. const navboardsSpan = document.getElementById("navBoardsSpan");
  891. if (navboardsSpan) {
  892. observer.observe(navboardsSpan, config);
  893. }
  894. }
  895.  
  896. // --- Feature: Save Scroll Position ---
  897. async function featureSaveScrollPosition() {
  898. const MAX_PAGES = 50;
  899. const currentPage = window.location.href;
  900. const excludedPagePatterns = [/\/catalog\.html$/i];
  901.  
  902. function isExcludedPage(url) {
  903. return excludedPagePatterns.some((pattern) => pattern.test(url));
  904. }
  905.  
  906. async function saveScrollPosition() {
  907. if (isExcludedPage(currentPage)) return;
  908.  
  909. const scrollPosition = window.scrollY;
  910. const timestamp = Date.now();
  911.  
  912. // Store both the scroll position and timestamp using GM storage
  913. await GM.setValue(
  914. `8chanSS_scrollPosition_${currentPage}`,
  915. JSON.stringify({
  916. position: scrollPosition,
  917. timestamp: timestamp,
  918. })
  919. );
  920.  
  921. await manageScrollStorage();
  922. }
  923.  
  924. async function manageScrollStorage() {
  925. // Get all GM storage keys
  926. const allKeys = await GM.listValues();
  927.  
  928. // Filter for scroll position keys
  929. const scrollKeys = allKeys.filter((key) =>
  930. key.startsWith("8chanSS_scrollPosition_")
  931. );
  932.  
  933. if (scrollKeys.length > MAX_PAGES) {
  934. // Create array of objects with key and timestamp
  935. const keyData = await Promise.all(
  936. scrollKeys.map(async (key) => {
  937. let data;
  938. try {
  939. const savedValue = await GM.getValue(key, null);
  940. if (savedValue) {
  941. data = JSON.parse(savedValue);
  942. // Handle legacy format (just a number)
  943. if (typeof data !== "object") {
  944. data = { position: parseFloat(savedValue), timestamp: 0 };
  945. }
  946. } else {
  947. data = { position: 0, timestamp: 0 };
  948. }
  949. } catch (e) {
  950. // If parsing fails, assume it's old format
  951. const savedValue = await GM.getValue(key, "0");
  952. data = {
  953. position: parseFloat(savedValue),
  954. timestamp: 0,
  955. };
  956. }
  957.  
  958. return {
  959. key: key,
  960. timestamp: data.timestamp || 0,
  961. };
  962. })
  963. );
  964.  
  965. // Sort by timestamp (oldest first)
  966. keyData.sort((a, b) => a.timestamp - b.timestamp);
  967.  
  968. // Remove oldest entries until we're under the limit
  969. const keysToRemove = keyData.slice(0, keyData.length - MAX_PAGES);
  970. for (const item of keysToRemove) {
  971. await GM.deleteValue(item.key);
  972. }
  973. }
  974. }
  975.  
  976. async function restoreScrollPosition() {
  977. // If the URL contains a hash (e.g. /res/1190.html#1534), do nothing
  978. if (window.location.hash && window.location.hash.length > 1) {
  979. return;
  980. }
  981.  
  982. const savedData = await GM.getValue(
  983. `8chanSS_scrollPosition_${currentPage}`,
  984. null
  985. );
  986.  
  987. if (savedData) {
  988. let position;
  989. try {
  990. // Try to parse as JSON (new format)
  991. const data = JSON.parse(savedData);
  992. position = data.position;
  993.  
  994. // Update the timestamp to "refresh" this entry
  995. await GM.setValue(
  996. `8chanSS_scrollPosition_${currentPage}`,
  997. JSON.stringify({
  998. position: position,
  999. timestamp: Date.now(),
  1000. })
  1001. );
  1002. } catch (e) {
  1003. // If parsing fails, assume it's the old format (just a number)
  1004. position = parseFloat(savedData);
  1005.  
  1006. // Convert to new format with current timestamp
  1007. await GM.setValue(
  1008. `8chanSS_scrollPosition_${currentPage}`,
  1009. JSON.stringify({
  1010. position: position,
  1011. timestamp: Date.now(),
  1012. })
  1013. );
  1014. }
  1015.  
  1016. if (!isNaN(position)) {
  1017. window.scrollTo(0, position);
  1018. }
  1019. }
  1020. }
  1021.  
  1022. // Use async event handlers
  1023. window.addEventListener("beforeunload", () => {
  1024. // We can't await in beforeunload, so we just call the function
  1025. saveScrollPosition();
  1026. });
  1027.  
  1028. // For load event, we can use an async function
  1029. window.addEventListener("load", async () => {
  1030. await restoreScrollPosition();
  1031. });
  1032.  
  1033. // Initial restore attempt (in case the load event already fired)
  1034. await restoreScrollPosition();
  1035. }
  1036.  
  1037. // --- Feature: Catalog & Image Hover ---
  1038. async function featureImageHover() {
  1039. // Accepts the thumb <img> node as the first argument
  1040. function getFullMediaSrcFromMime(thumbNode, filemime) {
  1041. if (!thumbNode || !filemime) return null;
  1042. const thumbnailSrc = thumbNode.getAttribute("src");
  1043.  
  1044. // If it's a t_ thumbnail, replace as before
  1045. if (/\/t_/.test(thumbnailSrc)) {
  1046. let base = thumbnailSrc.replace(/\/t_/, "/");
  1047. base = base.replace(/\.(jpe?g|png|gif|webp|webm|mp4)$/i, "");
  1048. const mimeToExt = {
  1049. "image/jpeg": ".jpg",
  1050. "image/jpg": ".jpg",
  1051. "image/png": ".png",
  1052. "image/gif": ".gif",
  1053. "image/webp": ".webp",
  1054. "image/bmp": ".bmp",
  1055. "video/mp4": ".mp4",
  1056. "video/webm": ".webm",
  1057. "audio/ogg": ".ogg",
  1058. "audio/mpeg": ".mp3",
  1059. "audio/x-m4a": ".m4a",
  1060. "audio/wav": ".wav",
  1061. };
  1062. const ext = mimeToExt[filemime.toLowerCase()];
  1063. if (!ext) return null;
  1064. return base + ext;
  1065. }
  1066.  
  1067. // If it's a /spoiler.png thumbnail or /a/custom.spoiler, use parent <a>'s href
  1068. if (
  1069. /\/spoiler\.png$/i.test(thumbnailSrc) ||
  1070. /\/a\/custom\.spoiler$/i.test(thumbnailSrc) ||
  1071. /\/audioGenericThumb\.png$/i.test(thumbnailSrc)
  1072. ) {
  1073. const parentA = thumbNode.closest("a.linkThumb, a.imgLink");
  1074. if (parentA && parentA.getAttribute("href")) {
  1075. // Use the full file URL from href
  1076. return parentA.getAttribute("href");
  1077. }
  1078. return null;
  1079. }
  1080.  
  1081. // Fallback: return null if not recognized
  1082. return null;
  1083. }
  1084.  
  1085. // Inject CSS for the audio indicator (only once)
  1086. if (!document.getElementById("audio-preview-indicator-style")) {
  1087. const style = document.createElement("style");
  1088. style.id = "audio-preview-indicator-style";
  1089. style.textContent = `
  1090. /* Make containers position:relative so absolute positioning works */
  1091. a.imgLink[data-filemime^="audio/"],
  1092. a.originalNameLink[href$=".mp3"],
  1093. a.originalNameLink[href$=".ogg"],
  1094. a.originalNameLink[href$=".m4a"],
  1095. a.originalNameLink[href$=".wav"] {
  1096. position: relative;
  1097. }
  1098.  
  1099. .audio-preview-indicator {
  1100. display: none;
  1101. position: absolute;
  1102. background: rgba(0, 0, 0, 0.7);
  1103. color: #ffffff;
  1104. padding: 5px;
  1105. font-size: 12px;
  1106. border-radius: 3px;
  1107. z-index: 1000;
  1108. left: 0;
  1109. top: 0;
  1110. white-space: nowrap;
  1111. pointer-events: none;
  1112. }
  1113.  
  1114. a[data-filemime^="audio/"]:hover .audio-preview-indicator,
  1115. a.originalNameLink:hover .audio-preview-indicator {
  1116. display: block;
  1117. }
  1118. `;
  1119. document.head.appendChild(style);
  1120. }
  1121.  
  1122. let floatingMedia = null;
  1123. let removeListeners = null;
  1124. let hoverTimeout = null;
  1125. let lastThumb = null;
  1126. let isStillHovering = false;
  1127.  
  1128. function cleanupFloatingMedia() {
  1129. if (hoverTimeout) {
  1130. clearTimeout(hoverTimeout);
  1131. hoverTimeout = null;
  1132. }
  1133.  
  1134. if (removeListeners) {
  1135. removeListeners();
  1136. removeListeners = null;
  1137. }
  1138.  
  1139. if (floatingMedia) {
  1140. if (
  1141. floatingMedia.tagName === "VIDEO" ||
  1142. floatingMedia.tagName === "AUDIO"
  1143. ) {
  1144. try {
  1145. floatingMedia.pause();
  1146. floatingMedia.removeAttribute("src");
  1147. floatingMedia.load();
  1148. } catch (e) {
  1149. // Silently handle media cleanup errors
  1150. }
  1151. }
  1152.  
  1153. if (floatingMedia.parentNode) {
  1154. floatingMedia.parentNode.removeChild(floatingMedia);
  1155. }
  1156. }
  1157.  
  1158. // Remove any audio indicators
  1159. const indicators = document.querySelectorAll(".audio-preview-indicator");
  1160. indicators.forEach((indicator) => {
  1161. if (indicator.parentNode) {
  1162. indicator.parentNode.removeChild(indicator);
  1163. }
  1164. });
  1165.  
  1166. floatingMedia = null;
  1167. lastThumb = null;
  1168. isStillHovering = false;
  1169. document.removeEventListener("mousemove", onMouseMove);
  1170. }
  1171.  
  1172. function onMouseMove(event) {
  1173. if (!floatingMedia) return;
  1174.  
  1175. const viewportWidth = window.innerWidth;
  1176. const viewportHeight = window.innerHeight;
  1177.  
  1178. // Determine media dimensions based on type
  1179. let mediaWidth = 0,
  1180. mediaHeight = 0;
  1181.  
  1182. if (floatingMedia.tagName === "IMG") {
  1183. mediaWidth =
  1184. floatingMedia.naturalWidth ||
  1185. floatingMedia.width ||
  1186. floatingMedia.offsetWidth ||
  1187. 0;
  1188. mediaHeight =
  1189. floatingMedia.naturalHeight ||
  1190. floatingMedia.height ||
  1191. floatingMedia.offsetHeight ||
  1192. 0;
  1193. } else if (floatingMedia.tagName === "VIDEO") {
  1194. mediaWidth = floatingMedia.videoWidth || floatingMedia.offsetWidth || 0;
  1195. mediaHeight =
  1196. floatingMedia.videoHeight || floatingMedia.offsetHeight || 0;
  1197. } else if (floatingMedia.tagName === "AUDIO") {
  1198. // Don't move audio elements - they're hidden anyway
  1199. return;
  1200. }
  1201.  
  1202. mediaWidth = Math.min(mediaWidth, viewportWidth * 0.9);
  1203. mediaHeight = Math.min(mediaHeight, viewportHeight * 0.9);
  1204.  
  1205. let newX = event.clientX + 10;
  1206. let newY = event.clientY + 10;
  1207.  
  1208. if (newX + mediaWidth > viewportWidth) {
  1209. newX = viewportWidth - mediaWidth - 10;
  1210. }
  1211. if (newY + mediaHeight > viewportHeight) {
  1212. newY = viewportHeight - mediaHeight - 10;
  1213. }
  1214.  
  1215. newX = Math.max(newX, 0);
  1216. newY = Math.max(newY, 0);
  1217.  
  1218. floatingMedia.style.left = `${newX}px`;
  1219. floatingMedia.style.top = `${newY}px`;
  1220. floatingMedia.style.maxWidth = "90vw";
  1221. floatingMedia.style.maxHeight = "90vh";
  1222. }
  1223.  
  1224. async function onThumbEnter(e) {
  1225. const thumb = e.currentTarget;
  1226. if (lastThumb === thumb) return;
  1227. lastThumb = thumb;
  1228.  
  1229. cleanupFloatingMedia();
  1230. isStillHovering = true;
  1231.  
  1232. // Get the actual container element (important for audio files)
  1233. const container =
  1234. thumb.tagName === "IMG"
  1235. ? thumb.closest("a.linkThumb, a.imgLink")
  1236. : thumb;
  1237.  
  1238. function onLeave() {
  1239. isStillHovering = false;
  1240. cleanupFloatingMedia();
  1241. }
  1242.  
  1243. thumb.addEventListener("mouseleave", onLeave, { once: true });
  1244.  
  1245. hoverTimeout = setTimeout(async () => {
  1246. hoverTimeout = null;
  1247. if (!isStillHovering) return;
  1248.  
  1249. let filemime = null;
  1250. let fullSrc = null;
  1251.  
  1252. // Case 1: Image/video thumbnail
  1253. if (thumb.tagName === "IMG") {
  1254. const parentA = thumb.closest("a.linkThumb, a.imgLink");
  1255. if (!parentA) return;
  1256.  
  1257. const href = parentA.getAttribute("href");
  1258. if (!href) return;
  1259.  
  1260. const ext = href.split(".").pop().toLowerCase();
  1261. filemime =
  1262. parentA.getAttribute("data-filemime") ||
  1263. {
  1264. jpg: "image/jpeg",
  1265. jpeg: "image/jpeg",
  1266. png: "image/png",
  1267. gif: "image/gif",
  1268. webp: "image/webp",
  1269. bmp: "image/bmp",
  1270. mp4: "video/mp4",
  1271. webm: "video/webm",
  1272. ogg: "audio/ogg",
  1273. mp3: "audio/mpeg",
  1274. m4a: "audio/x-m4a",
  1275. wav: "audio/wav",
  1276. }[ext];
  1277.  
  1278. fullSrc = getFullMediaSrcFromMime(thumb, filemime);
  1279. }
  1280. // Case 2: Audio file download link
  1281. else if (thumb.classList.contains("originalNameLink")) {
  1282. const href = thumb.getAttribute("href");
  1283. if (!href) return;
  1284.  
  1285. const ext = href.split(".").pop().toLowerCase();
  1286. if (["mp3", "ogg", "m4a", "wav"].includes(ext)) {
  1287. filemime = {
  1288. ogg: "audio/ogg",
  1289. mp3: "audio/mpeg",
  1290. m4a: "audio/x-m4a",
  1291. wav: "audio/wav",
  1292. }[ext];
  1293. fullSrc = href;
  1294. }
  1295. }
  1296.  
  1297. if (!fullSrc || !filemime) return;
  1298.  
  1299. let loaded = false;
  1300.  
  1301. // Helper to set common styles for floating media
  1302. function setCommonStyles(el) {
  1303. el.style.position = "fixed";
  1304. el.style.zIndex = 9999;
  1305. el.style.pointerEvents = "none";
  1306. el.style.maxWidth = "95vw";
  1307. el.style.maxHeight = "95vh";
  1308. el.style.transition = "opacity 0.15s";
  1309. el.style.opacity = "0";
  1310. el.style.left = "-9999px";
  1311. }
  1312.  
  1313. // Setup cleanup listeners
  1314. removeListeners = function () {
  1315. window.removeEventListener("scroll", cleanupFloatingMedia, true);
  1316. };
  1317. window.addEventListener("scroll", cleanupFloatingMedia, true);
  1318.  
  1319. // Handle different media types
  1320. if (filemime.startsWith("image/")) {
  1321. floatingMedia = document.createElement("img");
  1322. setCommonStyles(floatingMedia);
  1323.  
  1324. floatingMedia.onload = function () {
  1325. if (!loaded && floatingMedia && isStillHovering) {
  1326. loaded = true;
  1327. floatingMedia.style.opacity = "1";
  1328. document.body.appendChild(floatingMedia);
  1329. document.addEventListener("mousemove", onMouseMove);
  1330. onMouseMove(e);
  1331. }
  1332. };
  1333.  
  1334. floatingMedia.onerror = cleanupFloatingMedia;
  1335. floatingMedia.src = fullSrc;
  1336. } else if (filemime.startsWith("video/")) {
  1337. floatingMedia = document.createElement("video");
  1338. setCommonStyles(floatingMedia);
  1339.  
  1340. floatingMedia.autoplay = true;
  1341. floatingMedia.loop = true;
  1342. floatingMedia.muted = false;
  1343. floatingMedia.playsInline = true;
  1344. floatingMedia.controls = false; // No controls for videos
  1345.  
  1346. // Set volume from settings (0-100)
  1347. let volume = 50;
  1348. if (typeof getSetting === "function") {
  1349. try {
  1350. volume = await getSetting("hoverVideoVolume");
  1351. } catch (e) {
  1352. // Use default if setting can't be retrieved
  1353. }
  1354. }
  1355.  
  1356. if (typeof volume !== "number" || isNaN(volume)) volume = 50;
  1357. floatingMedia.volume = Math.max(0, Math.min(1, volume / 100));
  1358.  
  1359. floatingMedia.onloadeddata = function () {
  1360. if (!loaded && floatingMedia && isStillHovering) {
  1361. loaded = true;
  1362. floatingMedia.style.opacity = "1";
  1363. document.body.appendChild(floatingMedia);
  1364. document.addEventListener("mousemove", onMouseMove);
  1365. onMouseMove(e);
  1366. }
  1367. };
  1368.  
  1369. floatingMedia.onerror = cleanupFloatingMedia;
  1370. floatingMedia.src = fullSrc;
  1371. } else if (filemime.startsWith("audio/")) {
  1372. // --- AUDIO HOVER INDICATOR LOGIC ---
  1373. // Remove any lingering indicator first
  1374. const oldIndicator = container.querySelector(
  1375. ".audio-preview-indicator"
  1376. );
  1377. if (oldIndicator) oldIndicator.remove();
  1378.  
  1379. // Make sure container has position:relative for proper indicator positioning
  1380. if (container && !container.style.position) {
  1381. container.style.position = "relative";
  1382. }
  1383.  
  1384. floatingMedia = document.createElement("audio");
  1385. floatingMedia.src = fullSrc;
  1386. floatingMedia.volume = 0.5;
  1387. floatingMedia.controls = false; // No controls for audio
  1388. floatingMedia.style.display = "none"; // Hide the element visually
  1389. document.body.appendChild(floatingMedia);
  1390.  
  1391. // Add indicator to the container (parent a tag) instead of the img
  1392. const indicator = document.createElement("div");
  1393. indicator.classList.add("audio-preview-indicator");
  1394. indicator.textContent = "▶ Playing audio...";
  1395. container.appendChild(indicator);
  1396.  
  1397. floatingMedia.play().catch((error) => {
  1398. console.error("Audio playback failed:", error);
  1399. });
  1400.  
  1401. // Remove audio and indicator on click as well
  1402. function removeAudioAndIndicator() {
  1403. if (floatingMedia) {
  1404. floatingMedia.pause();
  1405. floatingMedia.currentTime = 0;
  1406. floatingMedia.remove();
  1407. floatingMedia = null;
  1408. }
  1409. if (indicator) {
  1410. indicator.remove();
  1411. }
  1412. }
  1413.  
  1414. container.addEventListener("click", removeAudioAndIndicator, {
  1415. once: true,
  1416. });
  1417. }
  1418. }, 120); // Short delay before showing preview
  1419. }
  1420.  
  1421. function attachThumbListeners(root = document) {
  1422. // Attach to image thumbnails (works for both thread and catalog)
  1423. const thumbs = root.querySelectorAll(
  1424. "a.linkThumb > img, a.imgLink > img"
  1425. );
  1426. thumbs.forEach((thumb) => {
  1427. if (!thumb._fullImgHoverBound) {
  1428. thumb.addEventListener("mouseenter", onThumbEnter);
  1429. thumb._fullImgHoverBound = true;
  1430. }
  1431. });
  1432.  
  1433. // Always attach to audio download links (both catalog and thread)
  1434. const audioLinks = root.querySelectorAll("a.originalNameLink");
  1435. audioLinks.forEach((link) => {
  1436. const href = link.getAttribute("href") || "";
  1437. const ext = href.split(".").pop().toLowerCase();
  1438. if (
  1439. ["mp3", "wav", "ogg", "m4a"].includes(ext) &&
  1440. !link._audioHoverBound
  1441. ) {
  1442. link.addEventListener("mouseenter", onThumbEnter);
  1443. link._audioHoverBound = true;
  1444. }
  1445. });
  1446. }
  1447.  
  1448. // Initial attachment
  1449. attachThumbListeners();
  1450.  
  1451. // Watch for new elements
  1452. const observer = new MutationObserver((mutations) => {
  1453. for (const mutation of mutations) {
  1454. for (const node of mutation.addedNodes) {
  1455. if (node.nodeType === Node.ELEMENT_NODE) {
  1456. attachThumbListeners(node);
  1457. }
  1458. }
  1459. }
  1460. });
  1461.  
  1462. observer.observe(document.body, { childList: true, subtree: true });
  1463. }
  1464.  
  1465. // --- Feature: Save Name Checkbox ---
  1466. // Pay attention that it needs to work on localStorage for the name key (not GM Storage)
  1467. function featureSaveNameCheckbox() {
  1468. const checkbox = document.createElement("input");
  1469. checkbox.type = "checkbox";
  1470. checkbox.id = "saveNameCheckbox";
  1471. checkbox.classList.add("postingCheckbox");
  1472. const label = document.createElement("label");
  1473. label.htmlFor = "saveNameCheckbox";
  1474. label.textContent = "Save Name";
  1475. label.title = "Save Name on refresh";
  1476. const alwaysUseBypassCheckbox = document.getElementById(
  1477. "qralwaysUseBypassCheckBox"
  1478. );
  1479. if (alwaysUseBypassCheckbox) {
  1480. alwaysUseBypassCheckbox.parentNode.insertBefore(
  1481. checkbox,
  1482. alwaysUseBypassCheckbox
  1483. );
  1484. alwaysUseBypassCheckbox.parentNode.insertBefore(
  1485. label,
  1486. checkbox.nextSibling
  1487. );
  1488. const savedCheckboxState =
  1489. localStorage.getItem("8chanSS_saveNameCheckbox") === "true";
  1490. checkbox.checked = savedCheckboxState;
  1491. const nameInput = document.getElementById("qrname");
  1492. if (nameInput) {
  1493. const savedName = localStorage.getItem("name");
  1494. if (checkbox.checked && savedName !== null) {
  1495. nameInput.value = savedName;
  1496. } else if (!checkbox.checked) {
  1497. nameInput.value = "";
  1498. }
  1499. nameInput.addEventListener("input", function () {
  1500. if (checkbox.checked) {
  1501. localStorage.setItem("name", nameInput.value);
  1502. }
  1503. });
  1504. checkbox.addEventListener("change", function () {
  1505. if (checkbox.checked) {
  1506. localStorage.setItem("name", nameInput.value);
  1507. } else {
  1508. localStorage.removeItem("name");
  1509. nameInput.value = "";
  1510. }
  1511. localStorage.setItem("8chanSS_saveNameCheckbox", checkbox.checked);
  1512. });
  1513. }
  1514. }
  1515. }
  1516.  
  1517. /* --- Feature: Blur Spoilers + Remove Spoilers suboption --- */
  1518. function featureBlurSpoilers() {
  1519. function revealSpoilers() {
  1520. const spoilerLinks = document.querySelectorAll("a.imgLink");
  1521. spoilerLinks.forEach(async (link) => {
  1522. const img = link.querySelector("img");
  1523. if (img) {
  1524. // Check if this is a custom spoiler image
  1525. const isCustomSpoiler = img.src.includes("/a/custom.spoiler");
  1526. // Check if this is NOT already a thumbnail
  1527. const isNotThumbnail = !img.src.includes("/.media/t_");
  1528.  
  1529. if (isNotThumbnail || isCustomSpoiler) {
  1530. let href = link.getAttribute("href");
  1531. if (href) {
  1532. // Extract filename without extension
  1533. const match = href.match(/\/\.media\/([^\/]+)\.[a-zA-Z0-9]+$/);
  1534. if (match) {
  1535. // Use the thumbnail path (t_filename)
  1536. const transformedSrc = `/\.media/t_${match[1]}`;
  1537. img.src = transformedSrc;
  1538.  
  1539. // If Remove Spoilers is enabled, do not apply blur, just show the thumbnail
  1540. if (await getSetting("blurSpoilers_removeSpoilers")) {
  1541. img.style.filter = "";
  1542. img.style.transition = "";
  1543. img.onmouseover = null;
  1544. img.onmouseout = null;
  1545. return;
  1546. } else {
  1547. img.style.filter = "blur(5px)";
  1548. img.style.transition = "filter 0.3s ease";
  1549. img.addEventListener("mouseover", () => {
  1550. img.style.filter = "none";
  1551. });
  1552. img.addEventListener("mouseout", () => {
  1553. img.style.filter = "blur(5px)";
  1554. });
  1555. }
  1556. }
  1557. }
  1558. }
  1559. }
  1560. });
  1561. }
  1562.  
  1563. // Initial run
  1564. revealSpoilers();
  1565.  
  1566. // Observe for dynamically added spoilers
  1567. const observer = new MutationObserver(revealSpoilers);
  1568. observer.observe(document.body, { childList: true, subtree: true });
  1569. }
  1570.  
  1571. // --- Feature Initialization based on Settings ---
  1572. // Because getSetting is now async, we need to await settings before running features.
  1573. // We'll use an async IIFE for initialization:
  1574.  
  1575. (async function initFeatures() {
  1576. // Always run hide/show feature (it will respect settings)
  1577. await featureCssClassToggles();
  1578.  
  1579. if (await getSetting("blurSpoilers")) {
  1580. featureBlurSpoilers();
  1581. }
  1582. if (await getSetting("enableHeaderCatalogLinks")) {
  1583. featureHeaderCatalogLinks();
  1584. }
  1585. if (await getSetting("enableScrollSave")) {
  1586. featureSaveScrollPosition();
  1587. }
  1588. if (await getSetting("enableSaveName")) {
  1589. featureSaveNameCheckbox();
  1590. }
  1591. if (await getSetting("enableScrollArrows")) {
  1592. featureScrollArrows();
  1593. }
  1594. if ((await getSetting("beepOnYou")) || (await getSetting("notifyOnYou"))) {
  1595. featureBeepOnYou();
  1596. }
  1597.  
  1598. // Check if we should enable image hover based on the current page
  1599. const isCatalogPage = /\/catalog\.html$/.test(
  1600. window.location.pathname.toLowerCase()
  1601. );
  1602. if (
  1603. (isCatalogPage && (await getSetting("enableCatalogImageHover"))) ||
  1604. (!isCatalogPage && (await getSetting("enableThreadImageHover")))
  1605. ) {
  1606. featureImageHover();
  1607. }
  1608. })();
  1609.  
  1610. // --- Feature: CSS Class Toggles ---
  1611. async function featureCssClassToggles() {
  1612. document.documentElement.classList.add("8chanSS");
  1613. const classToggles = {
  1614. enableFitReplies: "fit-replies",
  1615. enableSidebar: "ss-sidebar",
  1616. enableStickyQR: "sticky-qr",
  1617. enableBottomHeader: "bottom-header",
  1618. hideBanner: "disable-banner",
  1619. hidePostingForm: "hide-posting-form",
  1620. hideAnnouncement: "hide-announcement",
  1621. hidePanelMessage: "hide-panelmessage",
  1622. alwaysShowTW: "sticky-tw",
  1623. hidePostingForm_showCatalogForm: "show-catalog-form",
  1624. };
  1625. for (const [settingKey, className] of Object.entries(classToggles)) {
  1626. if (await getSetting(settingKey)) {
  1627. document.documentElement.classList.add(className);
  1628. } else {
  1629. document.documentElement.classList.remove(className);
  1630. }
  1631. }
  1632. // URL-based class toggling
  1633. const urlClassMap = [
  1634. { pattern: /\/catalog\.html$/i, className: "is-catalog" },
  1635. { pattern: /\/res\/[^/]+\.html$/i, className: "is-thread" },
  1636. { pattern: /^\/$/, className: "is-index" },
  1637. ];
  1638. const currentPath = window.location.pathname.toLowerCase();
  1639. urlClassMap.forEach(({ pattern, className }) => {
  1640. if (pattern.test(currentPath)) {
  1641. document.documentElement.classList.add(className);
  1642. } else {
  1643. document.documentElement.classList.remove(className);
  1644. }
  1645. });
  1646. }
  1647.  
  1648. // Init
  1649. featureCssClassToggles();
  1650.  
  1651. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  1652.  
  1653. // ---- Feature: Thread Watcher Things ---
  1654. // Move new post notification
  1655. function moveWatchedNotification() {
  1656. document.querySelectorAll(".watchedCellLabel").forEach((label) => {
  1657. const notif = label.querySelector(".watchedNotification");
  1658. const link = label.querySelector("a");
  1659. if (notif && link && notif.nextSibling !== link) {
  1660. label.insertBefore(notif, link);
  1661. }
  1662. });
  1663. }
  1664.  
  1665. // Initial run
  1666. moveWatchedNotification();
  1667.  
  1668. // Observe for dynamic changes in the watched menu
  1669. const watchedMenu = document.getElementById("watchedMenu");
  1670. if (watchedMenu) {
  1671. const observer = new MutationObserver(() => moveWatchedNotification());
  1672. observer.observe(watchedMenu, { childList: true, subtree: true });
  1673. }
  1674.  
  1675. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  1676.  
  1677. // --- Keyboard Shortcuts ---
  1678. // Open 8chanSS menu (CTRL + F1)
  1679. document.addEventListener("keydown", async function (event) {
  1680. if (event.ctrlKey && event.key === "F1") {
  1681. event.preventDefault(); // Prevent browser help
  1682. let menu =
  1683. document.getElementById("8chanSS-menu") || (await createSettingsMenu());
  1684. menu.style.display =
  1685. menu.style.display === "none" || menu.style.display === ""
  1686. ? "block"
  1687. : "none";
  1688. }
  1689. });
  1690.  
  1691. // Submit post (CTRL + Enter)
  1692. function submitWithCtrlEnter(event) {
  1693. // Check if Ctrl + Enter is pressed
  1694. if (event.ctrlKey && event.key === "Enter") {
  1695. event.preventDefault(); // Prevent default behavior
  1696.  
  1697. // Find and click the submit button
  1698. const submitButton = document.getElementById("qrbutton");
  1699. if (submitButton) {
  1700. submitButton.click();
  1701. }
  1702. }
  1703. }
  1704.  
  1705. // Add the event listener to the reply textarea
  1706. const replyTextarea = document.getElementById("qrbody");
  1707. if (replyTextarea) {
  1708. replyTextarea.addEventListener("keydown", submitWithCtrlEnter);
  1709. }
  1710.  
  1711. // QR (CTRL + Q)
  1712. function toggleQR(event) {
  1713. // Check if Ctrl + Q is pressed
  1714. if (event.ctrlKey && (event.key === "q" || event.key === "Q")) {
  1715. const hiddenDiv = document.getElementById("quick-reply");
  1716. // Toggle QR
  1717. if (
  1718. hiddenDiv.style.display === "none" ||
  1719. hiddenDiv.style.display === ""
  1720. ) {
  1721. hiddenDiv.style.display = "block"; // Show the div
  1722.  
  1723. // Focus the textarea after a small delay to ensure it's visible
  1724. setTimeout(() => {
  1725. const textarea = document.getElementById("qrbody");
  1726. if (textarea) {
  1727. textarea.focus();
  1728. }
  1729. }, 50);
  1730. } else {
  1731. hiddenDiv.style.display = "none"; // Hide the div
  1732. }
  1733. }
  1734. }
  1735. document.addEventListener("keydown", toggleQR);
  1736.  
  1737. // Clear textarea and hide quick-reply on Escape key
  1738. function clearTextarea(event) {
  1739. // Check if Escape key is pressed
  1740. if (event.key === "Escape") {
  1741. // Clear the textarea
  1742. const textarea = document.getElementById("qrbody");
  1743. if (textarea) {
  1744. textarea.value = ""; // Clear the textarea
  1745. }
  1746.  
  1747. // Hide the quick-reply div
  1748. const quickReply = document.getElementById("quick-reply");
  1749. if (quickReply) {
  1750. quickReply.style.display = "none"; // Hide the quick-reply
  1751. }
  1752. }
  1753. }
  1754. document.addEventListener("keydown", clearTextarea);
  1755.  
  1756. // Tags
  1757. const bbCodeCombinations = new Map([
  1758. ["s", ["[spoiler]", "[/spoiler]"]],
  1759. ["b", ["'''", "'''"]],
  1760. ["u", ["__", "__"]],
  1761. ["i", ["''", "''"]],
  1762. ["d", ["[doom]", "[/doom]"]],
  1763. ["m", ["[moe]", "[/moe]"]],
  1764. ["c", ["[code]", "[/code]"]],
  1765. ]);
  1766.  
  1767. function replyKeyboardShortcuts(ev) {
  1768. const key = ev.key.toLowerCase();
  1769. // Special case: alt+c for [code] tag
  1770. if (
  1771. key === "c" &&
  1772. ev.altKey &&
  1773. !ev.ctrlKey &&
  1774. bbCodeCombinations.has(key)
  1775. ) {
  1776. ev.preventDefault();
  1777. const textBox = ev.target;
  1778. const [openTag, closeTag] = bbCodeCombinations.get(key);
  1779. const { selectionStart, selectionEnd, value } = textBox;
  1780. if (selectionStart === selectionEnd) {
  1781. // No selection: insert empty tags and place cursor between them
  1782. const before = value.slice(0, selectionStart);
  1783. const after = value.slice(selectionEnd);
  1784. const newCursor = selectionStart + openTag.length;
  1785. textBox.value = before + openTag + closeTag + after;
  1786. textBox.selectionStart = textBox.selectionEnd = newCursor;
  1787. } else {
  1788. // Replace selected text with tags around it
  1789. const before = value.slice(0, selectionStart);
  1790. const selected = value.slice(selectionStart, selectionEnd);
  1791. const after = value.slice(selectionEnd);
  1792. textBox.value = before + openTag + selected + closeTag + after;
  1793. // Keep selection around the newly wrapped text
  1794. textBox.selectionStart = selectionStart + openTag.length;
  1795. textBox.selectionEnd = selectionEnd + openTag.length;
  1796. }
  1797. return;
  1798. }
  1799. // All other tags: ctrl+key
  1800. if (
  1801. ev.ctrlKey &&
  1802. !ev.altKey &&
  1803. bbCodeCombinations.has(key) &&
  1804. key !== "c"
  1805. ) {
  1806. ev.preventDefault();
  1807. const textBox = ev.target;
  1808. const [openTag, closeTag] = bbCodeCombinations.get(key);
  1809. const { selectionStart, selectionEnd, value } = textBox;
  1810. if (selectionStart === selectionEnd) {
  1811. // No selection: insert empty tags and place cursor between them
  1812. const before = value.slice(0, selectionStart);
  1813. const after = value.slice(selectionEnd);
  1814. const newCursor = selectionStart + openTag.length;
  1815. textBox.value = before + openTag + closeTag + after;
  1816. textBox.selectionStart = textBox.selectionEnd = newCursor;
  1817. } else {
  1818. // Replace selected text with tags around it
  1819. const before = value.slice(0, selectionStart);
  1820. const selected = value.slice(selectionStart, selectionEnd);
  1821. const after = value.slice(selectionEnd);
  1822. textBox.value = before + openTag + selected + closeTag + after;
  1823. // Keep selection around the newly wrapped text
  1824. textBox.selectionStart = selectionStart + openTag.length;
  1825. textBox.selectionEnd = selectionEnd + openTag.length;
  1826. }
  1827. return;
  1828. }
  1829. }
  1830. document
  1831. .getElementById("qrbody")
  1832. ?.addEventListener("keydown", replyKeyboardShortcuts);
  1833.  
  1834. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  1835.  
  1836. // Custom CSS injection
  1837. function addCustomCSS(css) {
  1838. if (!css) return;
  1839. const style = document.createElement("style");
  1840. style.type = "text/css";
  1841. style.appendChild(document.createTextNode(css));
  1842. document.head.appendChild(style);
  1843. }
  1844. // Get the current URL path
  1845. const currentPath = window.location.pathname.toLowerCase();
  1846. const currentHost = window.location.hostname.toLowerCase();
  1847.  
  1848. // Apply CSS based on URL pattern
  1849. if (/^8chan\.(se|moe)$/.test(currentHost)) {
  1850. // General CSS for all pages
  1851. const css = `
  1852. /* Margins */
  1853. :not(.is-catalog) body {
  1854. margin: 0;
  1855. }
  1856. :root.ss-sidebar #mainPanel {
  1857. margin-right: 305px;
  1858. }
  1859. /* Cleanup */
  1860. :root.hide-posting-form #postingForm,
  1861. :root.hide-announcement #dynamicAnnouncement,
  1862. :root.hide-panelmessage #panelMessage,
  1863. #navFadeEnd,
  1864. #navFadeMid,
  1865. #navTopBoardsSpan {
  1866. display: none;
  1867. }
  1868. :root.is-catalog.show-catalog-form #postingForm {
  1869. display: block !important;
  1870. }
  1871. footer {
  1872. visibility: hidden;
  1873. height: 0;
  1874. }
  1875. /* Header */
  1876. :not(:root.bottom-header) .navHeader {
  1877. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
  1878. }
  1879. :root.bottom-header nav.navHeader {
  1880. top: auto !important;
  1881. bottom: 0 !important;
  1882. box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.15);
  1883. }
  1884. /* Thread Watcher */
  1885. :root.sticky-tw #watchedMenu {
  1886. display: flex !important;
  1887. }
  1888. #watchedMenu {
  1889. font-size: smaller;
  1890. padding: 5px !important;
  1891. box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
  1892. }
  1893. #watchedMenu,
  1894. #watchedMenu .floatingContainer {
  1895. min-width: 200px;
  1896. }
  1897. #watchedMenu .watchedCellLabel > a:after {
  1898. content: " - "attr(href);
  1899. filter: saturate(50%);
  1900. font-style: italic;
  1901. font-weight: bold;
  1902. }
  1903. td.watchedCell > label.watchedCellLabel {
  1904. text-overflow: ellipsis;
  1905. overflow: hidden;
  1906. white-space: nowrap;
  1907. width: 180px;
  1908. display: block;
  1909. }
  1910. td.watchedCell > label.watchedCellLabel:hover {
  1911. overflow: unset;
  1912. width: auto;
  1913. white-space: normal;
  1914. }
  1915. .watchedNotification::before {
  1916. padding-right: 2px;
  1917. }
  1918. /* Posts */
  1919. :root.ss-sidebar .quoteTooltip {
  1920. /* Prevent quotes from overlapping the sidebar */
  1921. max-width: calc(100vw - 305px - 24px);
  1922. right: 322px;
  1923. word-wrap: anywhere;
  1924. }
  1925. .quoteTooltip .innerPost {
  1926. overflow: hidden;
  1927. box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
  1928. }
  1929. :root.fit-replies :not(.hidden).innerPost {
  1930. margin-left: 10px;
  1931. display: flow-root;
  1932. }
  1933. :root.fit-replies .quoteTooltip {
  1934. display: table !important;
  1935. }
  1936. .scroll-arrow-btn {
  1937. position: fixed;
  1938. right: 50px;
  1939. width: 36px;
  1940. height: 35px;
  1941. background: #222;
  1942. color: #fff;
  1943. border: none;
  1944. border-radius: 50%;
  1945. box-shadow: 0 2px 8px rgba(0,0,0,0.18);
  1946. font-size: 22px;
  1947. cursor: pointer;
  1948. opacity: 0.7;
  1949. z-index: 99998;
  1950. display: flex;
  1951. align-items: center;
  1952. justify-content: center;
  1953. transition: opacity 0.2s, background 0.2s;
  1954. }
  1955. :root.ss-sidebar .scroll-arrow-btn {
  1956. right: 330px !important;
  1957. }
  1958. .scroll-arrow-btn:hover {
  1959. opacity: 1;
  1960. background: #444;
  1961. }
  1962. #scroll-arrow-up {
  1963. bottom: 80px;
  1964. }
  1965. #scroll-arrow-down {
  1966. bottom: 32px;
  1967. }
  1968. `;
  1969. addCustomCSS(css);
  1970. }
  1971.  
  1972. // Thread page CSS
  1973. if (/\/res\/[^/]+\.html$/.test(currentPath)) {
  1974. const css = `
  1975. /* Quick Reply */
  1976. :root.sticky-qr #quick-reply {
  1977. display: block;
  1978. top: auto !important;
  1979. bottom: 0;
  1980. left: auto !important;
  1981. position: fixed;
  1982. right: 0 !important;
  1983. }
  1984. :root.sticky-qr #qrbody {
  1985. resize: vertical;
  1986. max-height: 50vh;
  1987. height: 130px;
  1988. }
  1989. #qrbody {
  1990. min-width: 300px;
  1991. }
  1992. :root.bottom-header #quick-reply {
  1993. bottom: 28px !important;
  1994. }
  1995. #quick-reply {
  1996. padding: 0;
  1997. opacity: 0.7;
  1998. transition: opacity 0.3s ease;
  1999. }
  2000. #quick-reply:hover,
  2001. #quick-reply:focus-within {
  2002. opacity: 1;
  2003. }
  2004. .floatingMenu {
  2005. padding: 0 !important;
  2006. }
  2007. #qrFilesBody {
  2008. max-width: 300px;
  2009. }
  2010. /* Banner */
  2011. :root.disable-banner #bannerImage {
  2012. display: none;
  2013. }
  2014. :root.ss-sidebar #bannerImage {
  2015. width: 305px;
  2016. right: 0;
  2017. position: fixed;
  2018. top: 26px;
  2019. }
  2020. :root.ss-sidebar.bottom-header #bannerImage {
  2021. top: 0 !important;
  2022. }
  2023. .innerUtility.top {
  2024. margin-top: 2em;
  2025. background-color: transparent !important;
  2026. color: var(--link-color) !important;
  2027. }
  2028. .innerUtility.top a {
  2029. color: var(--link-color) !important;
  2030. }
  2031. .quoteTooltip {
  2032. z-index: 110;
  2033. }
  2034. /* (You) Replies */
  2035. .innerPost:has(.youName) {
  2036. border-left: dashed #68b723 3px;
  2037. }
  2038. .innerPost:has(.quoteLink.you) {
  2039. border-left: solid #dd003e 3px;
  2040. }
  2041. /* Filename & Thumbs */
  2042. .originalNameLink {
  2043. display: inline;
  2044. overflow-wrap: anywhere;
  2045. white-space: normal;
  2046. }
  2047. .multipleUploads .uploadCell:not(.expandedCell) {
  2048. max-width: 215px;
  2049. }
  2050. `;
  2051. addCustomCSS(css);
  2052. }
  2053.  
  2054. // Catalog page CSS
  2055. if (/\/catalog\.html$/.test(currentPath)) {
  2056. const css = `
  2057. #dynamicAnnouncement {
  2058. display: none;
  2059. }
  2060. #postingForm {
  2061. margin: 2em auto;
  2062. }
  2063. `;
  2064. addCustomCSS(css);
  2065. }
  2066. })();