4chan grid-based image gallery with zoom mode support for threads that allows you to browse images, and soundposts (images with sounds, webms with sounds) along with other utility features.
// ==UserScript== // @name 4chan Gallery // @namespace http://tampermonkey.net/ // @version 2026-05-28 (4.1) // @description 4chan grid-based image gallery with zoom mode support for threads that allows you to browse images, and soundposts (images with sounds, webms with sounds) along with other utility features. // @author TheDarkEnjoyer // @match https://boards.4chan.org/*/thread/* // @match https://boards.4chan.org/*/archive // @match https://boards.4channel.org/*/thread/* // @match https://boards.4channel.org/*/archive // @match https://warosu.org/*/* // @match https://archived.moe/*/* // @match https://archive.palanq.win/*/* // @match https://archive.4plebs.org/*/* // @match https://desuarchive.org/*/* // @match https://thebarchive.com/*/* // @match https://archiveofsins.com/*/* // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js // @grant GM_download // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @license GNU GPLv3 // ==/UserScript== (function () { "use strict"; // ═══════════════════════════════════════════════════════════════ // THEME CONSTANTS // ═══════════════════════════════════════════════════════════════ const T = Object.freeze({ bg: "#181a1f", bgCard: "#282c34", bgPanel: "#21252b", bgSettings: "#1e2227", text: "#abb2bf", textMuted: "#5c6370", border: "#3e4452", blue: "#61afef", green: "#a6e22e", pink: "#f92672", purple: "#ae81ff", gold: "#e5c07b", dark: "#181a1f", overlay: "rgba(24, 26, 31, 0.88)", shadow: "rgba(0, 0, 0, 0.3)", shadowHeavy: "rgba(0, 0, 0, 0.6)", blueShadow: "rgba(97, 175, 239, 0.3)", greenShadow: "rgba(166, 226, 46, 0.3)", goldShadow: "rgba(229, 192, 123, 0.4)", purpleShadow:"rgba(174, 129, 255, 0.4)", font: "system-ui, -apple-system, 'Segoe UI', sans-serif", mono: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace", }); // ═══════════════════════════════════════════════════════════════ // CSS STYLESHEET // ═══════════════════════════════════════════════════════════════ function injectStyles() { const css = ` /* ── Animations ──────────────────────────────────── */ @keyframes gcFadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes gcFadeOut { from { opacity: 1; } to { opacity: 0; } } @keyframes gcSpinIn { from { opacity: 0; transform: scale(0.92); } to { opacity: 1; transform: scale(1); } } @keyframes gcSpin { to { transform: rotate(360deg); } } @keyframes gcPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* ── Gallery Button (page) ──────────────────────── */ #openImageGallery { position: fixed; bottom: 20px; right: 20px; z-index: 1000; background: linear-gradient(135deg, ${T.bgCard} 0%, ${T.bgPanel} 100%); color: ${T.text}; padding: 10px 20px; border-radius: 10px; border: 1px solid ${T.border}; cursor: pointer; box-shadow: 0 4px 16px ${T.shadow}; font-family: ${T.font}; font-weight: 600; font-size: 13px; letter-spacing: 0.3px; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } #openImageGallery:hover { background: linear-gradient(135deg, ${T.blue} 0%, #4fa3e8 100%) !important; color: ${T.dark} !important; transform: translateY(-3px) scale(1.05) !important; box-shadow: 0 8px 24px ${T.blueShadow} !important; border-color: ${T.blue} !important; } /* ── Gallery Overlay ─────────────────────────────── */ .gc-overlay { position: fixed; inset: 0; background: ${T.overlay}; backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); display: flex; justify-content: center; align-items: center; z-index: 9999; animation: gcFadeIn 0.25s ease-out; } /* ── Grid Container ──────────────────────────────── */ .gc-grid { display: grid; grid-auto-rows: max-content; gap: 14px; padding: 24px; background: ${T.bg}; color: ${T.text}; max-width: 80%; max-height: 80%; overflow: auto; resize: both; border: 1px solid ${T.border}; border-radius: 14px; box-shadow: 0 24px 64px ${T.shadowHeavy}; scrollbar-width: thin; scrollbar-color: ${T.border} ${T.bg}; align-content: start; } .gc-grid::-webkit-scrollbar { width: 8px; } .gc-grid::-webkit-scrollbar-track { background: ${T.bg}; border-radius: 4px; } .gc-grid::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 4px; } .gc-grid::-webkit-scrollbar-thumb:hover { background: ${T.textMuted}; } /* ── Grid Cell ───────────────────────────────────── */ .gc-cell { border: 1px solid ${T.border}; border-radius: 10px; background: ${T.bgCard}; position: relative; overflow: hidden; box-shadow: 0 4px 12px ${T.shadow}; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); will-change: transform; } .gc-cell:hover { transform: scale(1.02); border-color: ${T.blue}; box-shadow: 0 12px 32px ${T.shadowHeavy}; } .gc-cell--external { border-color: ${T.purple}; } .gc-cell--external:hover { border-color: ${T.purple}; box-shadow: 0 12px 32px ${T.purpleShadow}; } .gc-cell--dragging { opacity: 0.5; transform: scale(0.95); } .gc-cell--dragover { border-color: ${T.green} !important; box-shadow: 0 0 20px ${T.greenShadow} !important; } /* ── Cell Media Container ────────────────────────── */ .gc-media-wrap { position: relative; display: flex; justify-content: center; align-items: center; background: ${T.bg}; } .gc-media-wrap img, .gc-media-wrap video { max-width: 100%; object-fit: contain; cursor: pointer; display: block; } /* ── Video Badge ─────────────────────────────────── */ .gc-video-badge { position: absolute; top: 8px; left: 8px; background: linear-gradient(135deg, ${T.purple} 0%, #9b59e0 100%); color: ${T.dark}; padding: 3px 8px; border-radius: 5px; font-size: 10px; font-weight: 700; font-family: ${T.mono}; pointer-events: none; z-index: 2; box-shadow: 0 2px 8px ${T.purpleShadow}; letter-spacing: 0.5px; display: flex; align-items: center; gap: 3px; } .gc-sound-badge { background: linear-gradient(135deg, ${T.gold} 0%, #d4a853 100%); } /* ── Cell Controls Bar ───────────────────────────── */ .gc-controls { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; background: ${T.bgPanel}; border-top: 1px solid ${T.border}; gap: 6px; } /* ── Buttons ─────────────────────────────────────── */ .gc-btn { background: ${T.bgCard}; color: ${T.text}; padding: 6px 12px; border-radius: 7px; border: 1px solid ${T.border}; cursor: pointer; box-shadow: 0 2px 6px ${T.shadow}; font-family: ${T.font}; font-weight: 500; font-size: 12px; transition: all 0.2s ease; white-space: nowrap; line-height: 1.4; display: inline-flex; align-items: center; gap: 4px; text-decoration: none; } .gc-btn:hover { background: ${T.blue}; color: ${T.dark}; border-color: ${T.blue}; box-shadow: 0 4px 14px ${T.blueShadow}; transform: translateY(-1px); } .gc-btn--primary { background: linear-gradient(135deg, ${T.blue} 0%, #4fa3e8 100%); color: ${T.dark}; border-color: ${T.blue}; font-weight: 600; } .gc-btn--primary:hover { box-shadow: 0 6px 18px ${T.blueShadow}; } .gc-btn--success { background: linear-gradient(135deg, ${T.green} 0%, #8cc41a 100%); color: ${T.dark}; border: none; font-weight: 600; } .gc-btn--success:hover { background: linear-gradient(135deg, #b8e85a 0%, ${T.green} 100%); box-shadow: 0 6px 18px ${T.greenShadow}; color: ${T.dark}; } .gc-btn--large { padding: 10px 20px; font-size: 13px; font-weight: 600; } .gc-btn--small { padding: 4px 8px; font-size: 11px; } .gc-btn--icon { padding: 6px 10px; font-size: 14px; font-weight: bold; line-height: 1; } .gc-btn--bookmark { transition: all 0.2s ease; } .gc-btn--bookmark:hover { background: ${T.gold} !important; color: ${T.dark} !important; border-color: ${T.gold} !important; box-shadow: 0 4px 14px ${T.goldShadow} !important; } .gc-btn--bookmark-active { background: ${T.gold}; color: ${T.dark}; border-color: ${T.gold}; } .gc-btn--bookmark-active:hover { background: #d4a853 !important; } /* ── Top Bar (mode toggles) ──────────────────────── */ .gc-topbar { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; z-index: 10000; } .gc-topbar-right { position: absolute; top: 12px; right: 12px; display: flex; gap: 8px; z-index: 10000; } /* ── Zoom Overlay ────────────────────────────────── */ .gc-zoom { position: fixed; inset: 0; background: ${T.overlay}; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); z-index: 9999; animation: gcFadeIn 0.2s ease-out; } /* ── Zoom Media ──────────────────────────────────── */ .gc-zoom-media { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 10000; height: 80%; width: 80%; object-fit: contain; cursor: pointer; } /* ── Zoom Arrow Buttons ──────────────────────────── */ .gc-zoom-arrow { position: fixed; top: 50%; transform: translateY(-50%); z-index: 10001; background: linear-gradient(135deg, ${T.bgPanel} 0%, ${T.bgCard} 100%); color: ${T.text}; padding: 16px; border: 1px solid ${T.border}; border-radius: 50%; cursor: pointer; font-size: 16px; transition: all 0.2s ease; line-height: 1; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; } .gc-zoom-arrow--left { left: 20px; } .gc-zoom-arrow--right { right: 20px; } .gc-zoom-arrow:hover { background: linear-gradient(135deg, ${T.green} 0%, #8cc41a 100%); color: ${T.dark}; transform: translateY(-50%) scale(1.12); box-shadow: 0 6px 18px ${T.greenShadow}; border-color: ${T.green}; } /* ── Zoom Pill Indicators ────────────────────────── */ .gc-pill { position: fixed; top: 15px; background: ${T.bgPanel}; padding: 6px 14px; border-radius: 20px; border: 1px solid ${T.border}; box-shadow: 0 4px 12px ${T.shadow}; z-index: 10000; font-family: ${T.font}; font-weight: 500; font-size: 13px; color: ${T.text}; } .gc-pill--index { left: 15px; font-family: ${T.mono}; font-weight: 700; color: ${T.gold}; border-color: ${T.gold}; box-shadow: 0 0 12px rgba(229, 192, 123, 0.15); } .gc-pill--title { right: 15px; } /* ── Zoom Bottom Controls ────────────────────────── */ .gc-zoom-controls { position: fixed; bottom: 12px; left: 0; right: 0; z-index: 10000; display: flex; justify-content: space-around; align-items: center; padding: 0 20px; } .gc-zoom-btn-group { display: flex; gap: 8px; align-items: center; } /* ── Settings Modal ──────────────────────────────── */ .gc-settings-overlay { position: fixed; inset: 0; background: ${T.overlay}; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); display: flex; justify-content: center; align-items: center; z-index: 10001; animation: gcFadeIn 0.25s ease-out; } .gc-settings-box { background: ${T.bgSettings}; color: ${T.text}; padding: 32px; border-radius: 14px; border: 1px solid ${T.border}; max-width: 700px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 24px 64px ${T.shadowHeavy}; animation: gcSpinIn 0.3s ease-out; } .gc-settings-title { text-align: center; margin: 0 0 24px; color: ${T.pink}; font-family: ${T.font}; font-weight: 700; font-size: 22px; letter-spacing: 0.5px; } .gc-settings-list { list-style: none; padding: 0; margin: 0; } .gc-settings-item { display: flex; align-items: center; margin-bottom: 14px; padding: 10px 12px; border-radius: 8px; transition: background 0.2s ease; } .gc-settings-item:hover { background: rgba(255, 255, 255, 0.03); } .gc-settings-label { flex: 1; font-size: 13px; cursor: help; } .gc-settings-input { padding: 8px 12px; border-radius: 7px; border: 1px solid ${T.border}; background: ${T.bg}; color: ${T.text}; font-family: ${T.font}; font-size: 13px; transition: all 0.2s ease; outline: none; } .gc-settings-input:focus { border-color: ${T.blue}; box-shadow: 0 0 0 3px rgba(97, 175, 239, 0.2); } .gc-settings-input[type="checkbox"] { width: 18px; height: 18px; accent-color: ${T.blue}; cursor: pointer; } .gc-settings-input[type="number"], .gc-settings-input[type="text"] { min-width: 80px; } .gc-settings-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; } /* ── Bookmarks Info Card ──────────────────────────── */ .gc-bm-info { padding: 10px 12px; background: ${T.bgPanel}; border-top: 1px solid ${T.border}; display: flex; flex-direction: column; gap: 6px; } .gc-bm-meta { display: flex; justify-content: space-between; font-size: 10px; color: ${T.textMuted}; } .gc-bm-comment { font-size: 11px; color: ${T.text}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; cursor: help; } /* ── Loading Spinner ─────────────────────────────── */ .gc-spinner { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px; gap: 14px; } .gc-spinner-ring { width: 36px; height: 36px; border: 3px solid ${T.border}; border-top-color: ${T.blue}; border-radius: 50%; animation: gcSpin 0.8s linear infinite; } .gc-spinner-text { color: ${T.textMuted}; font-size: 13px; font-style: italic; animation: gcPulse 1.5s ease-in-out infinite; } /* ── Empty State ─────────────────────────────────── */ .gc-empty { grid-column: 1 / -1; text-align: center; padding: 48px; font-size: 16px; color: ${T.textMuted}; font-style: italic; } /* ── Scroll-to-last / Close buttons ──────────────── */ .gc-float-btn { position: fixed; z-index: 10000; transition: all 0.2s ease; } .gc-float-btn:hover { transform: translateY(-2px); } /* ── Media count indicator ───────────────────────── */ .gc-media-count { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 10000; background: ${T.bgPanel}; color: ${T.textMuted}; padding: 4px 14px; border-radius: 20px; border: 1px solid ${T.border}; font-family: ${T.mono}; font-size: 11px; font-weight: 600; pointer-events: none; transition: opacity 0.3s ease; } /* ── Top Bar Center (search) ─────────────────────── */ .gc-topbar-center { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; z-index: 10000; } .gc-search-input { padding: 8px 16px; border-radius: 20px; border: 1px solid ${T.border}; background: ${T.bgCard}; color: ${T.text}; font-family: ${T.font}; font-size: 12px; width: 200px; outline: none; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 4px 12px ${T.shadow}; } .gc-search-input:focus { border-color: ${T.blue}; box-shadow: 0 0 0 3px ${T.blueShadow}; width: 300px; } /* ── Metadata Viewer Panel ───────────────────────── */ .gc-meta-overlay { position: fixed; inset: 0; background: ${T.overlay}; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); display: flex; justify-content: center; align-items: center; z-index: 10002; animation: gcFadeIn 0.2s ease-out; } .gc-meta-box { background: ${T.bgSettings}; color: ${T.text}; padding: 28px; border-radius: 14px; border: 1px solid ${T.border}; width: 85%; max-width: 680px; max-height: 80vh; overflow-y: auto; box-shadow: 0 24px 64px ${T.shadowHeavy}; display: flex; flex-direction: column; gap: 16px; animation: gcSpinIn 0.25s ease-out; } .gc-meta-title { margin: 0; color: ${T.gold}; font-family: ${T.font}; font-weight: 700; font-size: 20px; letter-spacing: 0.5px; display: flex; align-items: center; justify-content: space-between; } .gc-meta-content { font-family: ${T.mono}; font-size: 12px; white-space: pre-wrap; background: ${T.bg}; padding: 16px; border-radius: 8px; border: 1px solid ${T.border}; max-height: 48vh; overflow-y: auto; user-select: text; color: ${T.text}; line-height: 1.5; } /* ── Changelog Modal ─────────────────────────────── */ .gc-changelog-overlay { position: fixed; inset: 0; background: ${T.overlay}; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); display: flex; justify-content: center; align-items: center; z-index: 10005; animation: gcFadeIn 0.25s ease-out; } .gc-changelog-box { background: ${T.bgSettings}; color: ${T.text}; padding: 30px; border-radius: 14px; border: 1px solid ${T.border}; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; box-shadow: 0 24px 64px ${T.shadowHeavy}; display: flex; flex-direction: column; gap: 20px; animation: gcSpinIn 0.3s ease-out; } .gc-changelog-header { margin: 0; color: ${T.gold}; font-family: ${T.font}; font-weight: 700; font-size: 22px; letter-spacing: 0.5px; border-bottom: 1px solid ${T.border}; padding-bottom: 12px; } .gc-changelog-list { display: flex; flex-direction: column; gap: 14px; padding: 0; margin: 0; list-style: none; } .gc-changelog-item { display: flex; flex-direction: column; gap: 4px; } .gc-changelog-title { font-weight: 600; font-size: 14px; color: ${T.blue}; display: flex; align-items: center; gap: 8px; } .gc-changelog-desc { font-size: 12px; color: ${T.text}; padding-left: 24px; line-height: 1.5; } .gc-changelog-btn-wrap { margin-top: 10px; display: flex; justify-content: center; } `; const el = document.createElement("style"); el.id = "gc-styles"; el.textContent = css; document.head.appendChild(el); } // ═══════════════════════════════════════════════════════════════ // HELPER FUNCTIONS // ═══════════════════════════════════════════════════════════════ /** Batch-set styles on an element */ function setStyles(el, styles) { for (const k in styles) el.style[k] = styles[k]; } /** Create a DOM element with optional styles, attributes, classes, children */ function h(tag, { s, a, c, ch, txt, html } = {}) { const el = document.createElement(tag); if (c) { const classes = (Array.isArray(c) ? c : [c]).filter(Boolean); if (classes.length > 0) { el.classList.add(...classes); } } if (a) { for (const k in a) { if (k === "dataset") { for (const dk in a.dataset) el.dataset[dk] = a.dataset[dk]; } else { el.setAttribute(k, a[k]); } } } if (s) setStyles(el, s); if (txt) el.textContent = txt; if (html) el.innerHTML = html; if (ch) { const children = Array.isArray(ch) ? ch : [ch]; children.forEach(child => { if (child) el.appendChild(child); }); } return el; } /** Create a button with consistent styling */ function btn(text, onClick, { classes = [], title: tip, attrs } = {}) { const b = h("button", { c: ["gc-btn", ...classes], txt: text, a: attrs }); if (tip) b.title = tip; if (onClick) b.addEventListener("click", onClick); return b; } /** Create an anchor styled as a button */ function btnLink(text, { classes = [], href, download: dl, title: tip, onClick } = {}) { const a = h("a", { c: ["gc-btn", ...classes], txt: text, a: { href: href || "#" } }); if (dl) a.download = dl; if (tip) a.title = tip; if (onClick) { a.addEventListener("click", (e) => { e.preventDefault(); onClick(e); }); } return a; } /** Debounce helper */ function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } /** Safe GM_getValue wrapper with fallback */ function getGMValue(key, defaultVal) { if (typeof GM_getValue !== "undefined") { try { return GM_getValue(key, defaultVal); } catch (e) { console.error("GM_getValue failed:", e); } } return defaultVal; } /** Safe GM_setValue wrapper with fallback */ function setGMValue(key, value) { if (typeof GM_setValue !== "undefined") { try { GM_setValue(key, value); return; } catch (e) { console.error("GM_setValue failed:", e); } } } // ═══════════════════════════════════════════════════════════════ // SETTINGS // ═══════════════════════════════════════════════════════════════ const defaultSettings = { Load_High_Res_Images_By_Default: { value: false, info: "When opening the gallery, load high quality images by default (no thumbnails)", }, Add_Placeholder_Image_For_Zoom_Mode: { value: true, info: "Add a placeholder image for zoom mode so even if the thread has no images, you can still open the zoom mode", }, Play_Webms_On_Hover: { value: true, info: "Autoplay webms on hover, pause on mouse leave", }, Switch_Catbox_To_Pixstash_For_Soundposts: { value: false, info: "Switch all catbox.moe links to pixstash.moe links for soundposts", }, Show_Arrow_Buttons_In_Zoom_Mode: { value: true, info: "Show clickable arrow buttons on screen edges in zoom mode", }, Grid_Columns: { value: 3, info: "Number of columns in the grid view", }, Grid_Cell_Max_Height: { value: 200, info: "Maximum height of each cell in pixels", }, Embed_External_Links: { value: false, info: "Embed catbox/pixstash links found in post comments", }, Strictly_Load_GIFs_As_Thumbnails_On_Hover: { value: false, info: "Only load GIF thumbnails until hovered", }, Open_Close_Gallery_Key: { value: "i", info: "Key to open/close the gallery", }, Hide_Gallery_Button: { value: false, info: "Hide the gallery button (You can still open the gallery with the keybind, default is 'i')", }, Force_Load_Catbox_Images_As_Webp: { value: false, info: "Load Catbox images as WebP format for faster loading (using images.weserv.nl)", }, }; function loadSettings() { let saved = null; const gmRaw = getGMValue("gallerySettings", null); if (gmRaw) { try { saved = typeof gmRaw === "string" ? JSON.parse(gmRaw) : gmRaw; } catch (e) { console.error("Failed to parse GM settings:", e); } } // Migrate from localStorage if (!saved) { try { const localRaw = localStorage.getItem("gallerySettings"); if (localRaw) { saved = JSON.parse(localRaw); setGMValue("gallerySettings", JSON.stringify(saved)); localStorage.removeItem("gallerySettings"); } } catch (e) { console.error("Failed to migrate settings:", e); } } if (!saved) { saved = JSON.parse(JSON.stringify(defaultSettings)); setGMValue("gallerySettings", JSON.stringify(saved)); return saved; } // Migrate: add missing keys, remove stale keys let dirty = false; for (const k in defaultSettings) { if (!saved.hasOwnProperty(k)) { saved[k] = { ...defaultSettings[k] }; dirty = true; } } for (const k in saved) { if (!defaultSettings.hasOwnProperty(k)) { delete saved[k]; dirty = true; } } if (dirty) setGMValue("gallerySettings", JSON.stringify(saved)); return saved; } function saveSettings(newSettings) { setGMValue("gallerySettings", JSON.stringify(newSettings)); } function loadBookmarks() { let bookmarks = {}; const gmRaw = getGMValue("galleryBookmarks", null); if (gmRaw) { try { bookmarks = typeof gmRaw === "string" ? JSON.parse(gmRaw) : gmRaw; } catch (e) { console.error("Failed to parse GM bookmarks:", e); } } // Migrate from localStorage try { const localRaw = localStorage.getItem("galleryBookmarks"); if (localRaw) { const localBookmarks = JSON.parse(localRaw); let migrated = false; for (const k in localBookmarks) { if (!bookmarks[k]) { bookmarks[k] = localBookmarks[k]; migrated = true; } } if (migrated) { setGMValue("galleryBookmarks", JSON.stringify(bookmarks)); } localStorage.removeItem("galleryBookmarks"); } } catch (e) { console.error("Failed to migrate bookmarks:", e); } return bookmarks; } // ═══════════════════════════════════════════════════════════════ // STATE // ═══════════════════════════════════════════════════════════════ const state = { settings: loadSettings(), bookmarks: loadBookmarks(), threadURL: window.location.href, lastScrollPos: 0, gallerySize: { w: 0, h: 0 }, currentView: "gallery", // "gallery" | "bookmarks" mode: "all", // "all" | "webm" autoPlayWebms: false, zoomActive: false, galleryOpen: false, mediaCount: 0, }; // Convenience getters function cfg(key) { return state.settings[key]?.value; } // ═══════════════════════════════════════════════════════════════ // BOOKMARKS // ═══════════════════════════════════════════════════════════════ function isBookmarked(url) { if (!url) return false; return Object.keys(state.bookmarks).some(k => k === url || state.bookmarks[k].url === url); } function findBookmarkKey(url) { if (!url) return null; return Object.keys(state.bookmarks).find(k => k === url || state.bookmarks[k].url === url); } function toggleBookmark(postData) { const key = postData.url; const existingKey = findBookmarkKey(key); if (existingKey) { delete state.bookmarks[existingKey]; } else { const soundLinkStr = postData.soundLink ? (Array.isArray(postData.soundLink) ? postData.soundLink[0] : postData.soundLink) : null; state.bookmarks[key] = { url: postData.url, thumbnailUrl: postData.thumbnailUrl, commentText: postData.commentText || "", board: postData.board || "", threadID: postData.threadID || "", postID: postData.postID || "", postURL: postData.postURL || window.location.href, timestamp: new Date().toISOString(), isExternal: postData.isExternal || false, soundLink: soundLinkStr, soundUrl: postData.soundUrl || null, }; triggerCatboxUpload(key); } setGMValue("galleryBookmarks", JSON.stringify(state.bookmarks)); return isBookmarked(key); } // ═══════════════════════════════════════════════════════════════ // DOWNLOAD // ═══════════════════════════════════════════════════════════════ function downloadMedia(url, filename) { if (typeof GM_download !== "undefined") { try { const dl = GM_download({ url, name: filename, onerror: (err) => { console.error("GM_download failed, trying fallback:", err); fallbackBlobDownload(url, filename); }, ontimeout: () => { console.warn("GM_download timed out, trying fallback"); fallbackBlobDownload(url, filename); }, saveAs: true, }); setTimeout(() => { if (dl?.abort) dl.abort(); }, 240000); return; } catch (e) { console.error("GM_download exception, trying fallback:", e); } } fallbackBlobDownload(url, filename); } function fallbackBlobDownload(url, filename) { fetch(url, { mode: "cors" }) .then((r) => { if (!r.ok) throw new Error("Fetch not ok"); return r.blob(); }) .then((blob) => { const blobUrl = URL.createObjectURL(blob); const a = h("a", { a: { href: blobUrl, download: filename } }); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(blobUrl); }) .catch((err) => { console.error("Blob download failed, opening new tab:", err); window.open(url, "_blank"); }); } // ═══════════════════════════════════════════════════════════════ // UTILITY // ═══════════════════════════════════════════════════════════════ function convertToCatboxWebp(url) { if (cfg("Force_Load_Catbox_Images_As_Webp") && url.includes("catbox.moe")) { return `https://images.weserv.nl/?url=${encodeURIComponent(url)}&output=webp`; } return url; } const FILE_EXT_RE = /\.(webm|mp4|jpg|jpeg|png|gif)$/i; const LINK_RE = /https:\/\/(files|litter)\.(catbox|pixstash)\.moe\/[a-z0-9]+\.(jpg|png|gif|webm|mp4)/g; const SOUND_RE = /\[sound=(.+?)\]/; function isVideoExt(ext) { return ext === "webm" || ext === "mp4"; } function isImageExt(ext) { return ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "gif"; } // -- Typed array buffers for PNG chunk extraction -- const uint8 = new Uint8Array(4); const int32 = new Int32Array(uint8.buffer); const uint32 = new Uint32Array(uint8.buffer); /** Asynchronously upload a URL's file contents to Catbox */ function uploadUrlToCatbox(url) { return new Promise((resolve, reject) => { if (!url) return reject("No URL provided"); if (url.includes("catbox.moe") || url.includes("pixstash.moe")) { return resolve(url); } GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", onload: (response) => { if (response.status !== 200 || !response.response) { reject("Failed to fetch media blob, status: " + response.status); return; } const blob = response.response; let filename = url.split("/").pop().split("?")[0] || "file"; if (!filename.includes(".")) { const mime = blob.type; let ext = "bin"; if (mime.includes("image/png")) ext = "png"; else if (mime.includes("image/jpeg")) ext = "jpg"; else if (mime.includes("image/gif")) ext = "gif"; else if (mime.includes("video/webm")) ext = "webm"; else if (mime.includes("video/mp4")) ext = "mp4"; else if (mime.includes("audio/mpeg")) ext = "mp3"; filename = `file.${ext}`; } const fd = new FormData(); fd.append("reqtype", "fileupload"); fd.append("fileToUpload", blob, filename); GM_xmlhttpRequest({ method: "POST", url: "https://catbox.moe/user/api.php", data: fd, onload: (uploadResponse) => { const resText = uploadResponse.responseText.trim(); if (uploadResponse.status === 200 && resText.startsWith("https://files.catbox.moe/")) { resolve(resText); } else { reject("Catbox upload failed: " + resText); } }, onerror: (uploadErr) => { reject("Catbox upload network error: " + uploadErr); } }); }, onerror: (err) => { reject("Failed to fetch media network error: " + err); } }); }); } /** Trigger background Catbox uploads for a bookmarked item */ function triggerCatboxUpload(key) { const bm = state.bookmarks[key]; if (!bm) return; const mediaNeedsUpload = !bm.url.includes("catbox.moe") && !bm.url.includes("pixstash.moe"); let soundUrl = bm.soundUrl; if (!soundUrl && bm.soundLink) { soundUrl = buildAudioSrc([null, bm.soundLink]); } const soundNeedsUpload = soundUrl && !soundUrl.includes("catbox.moe") && !soundUrl.includes("pixstash.moe"); if (!mediaNeedsUpload && !soundNeedsUpload) return; console.log(`[Catbox Archiver] Starting background upload for bookmark: ${bm.url}`); const promises = []; if (mediaNeedsUpload) { promises.push( uploadUrlToCatbox(bm.url) .then((catboxMediaUrl) => { if (state.bookmarks[key]) { state.bookmarks[key].url = catboxMediaUrl; state.bookmarks[key].thumbnailUrl = catboxMediaUrl; setGMValue("galleryBookmarks", JSON.stringify(state.bookmarks)); } return catboxMediaUrl; }) ); } else { promises.push(Promise.resolve(bm.url)); } if (soundNeedsUpload) { promises.push( uploadUrlToCatbox(soundUrl) .then((catboxSoundUrl) => { if (state.bookmarks[key]) { state.bookmarks[key].soundUrl = catboxSoundUrl; setGMValue("galleryBookmarks", JSON.stringify(state.bookmarks)); } return catboxSoundUrl; }) ); } else if (soundUrl) { promises.push(Promise.resolve(soundUrl)); } Promise.all(promises) .then(() => { console.log(`[Catbox Archiver] Successfully archived bookmark to Catbox: ${key}`); document.dispatchEvent(new CustomEvent("gc-bookmarks-updated")); }) .catch((err) => { console.error(`[Catbox Archiver] Error archiving bookmark to Catbox:`, err); }); } const loadImage = (blob) => { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { URL.revokeObjectURL(image.src); resolve(image); }; image.onerror = reject; image.src = URL.createObjectURL(blob); }); }; function imageHasAlpha(context, canvas) { const data = context.getImageData(0, 0, canvas.width, canvas.height).data; let hasAlphaPixels = false; for (let i = 3, n = data.length; i < n; i += 4) { if (data[i] < 255) { hasAlphaPixels = true; break; } } return hasAlphaPixels; } function readInfoFromImageStealth(image) { let geninfo, paramLen; let r, g, b, a; const canvas = document.createElement("canvas"); const [width, height] = [image.width, image.height]; const context = canvas.getContext("2d"); canvas.width = image.width; canvas.height = image.height; context.drawImage(image, 0, 0); const imageData = context.getImageData(0, 0, width, height); const data = imageData.data; let hasAlpha = imageHasAlpha(context, canvas); let mode = null; let compressed = false; let binaryData = ""; let bufferA = ""; let bufferRGB = ""; let indexA = 0; let indexRGB = 0; let sigConfirmed = false; let confirmingSignature = true; let readingParamLen = false; let readingParam = false; let readEnd = false; for (let x = 0; x < width; x++) { for (let y = 0; y < height; y++) { let i = (y * width + x) * 4; if (hasAlpha) { [r, g, b, a] = data.slice(i, i + 4); bufferA += (a & 1).toString(); indexA++; } else { [r, g, b] = data.slice(i, i + 3); } bufferRGB += (r & 1).toString(); bufferRGB += (g & 1).toString(); bufferRGB += (b & 1).toString(); indexRGB += 3; if (confirmingSignature) { if (indexA === "stealth_pnginfo".length * 8) { const decodedSig = new TextDecoder().decode(new Uint8Array(bufferA.match(/\d{8}/g).map(b => parseInt(b, 2)))); if (decodedSig === "stealth_pnginfo" || decodedSig === "stealth_pngcomp") { confirmingSignature = false; sigConfirmed = true; readingParamLen = true; mode = "alpha"; if (decodedSig === "stealth_pngcomp") { compressed = true; } bufferA = ""; indexA = 0; } else { readEnd = true; break; } } else if (indexRGB === "stealth_pnginfo".length * 8) { const decodedSig = new TextDecoder().decode(new Uint8Array(bufferRGB.match(/\d{8}/g).map(b => parseInt(b, 2)))); if (decodedSig === "stealth_rgbinfo" || decodedSig === "stealth_rgbcomp") { confirmingSignature = false; sigConfirmed = true; readingParamLen = true; mode = "rgb"; if (decodedSig === "stealth_rgbcomp") { compressed = true; } bufferRGB = ""; indexRGB = 0; } } } else if (readingParamLen) { if (mode === "alpha" && indexA === 32) { paramLen = parseInt(bufferA, 2); readingParamLen = false; readingParam = true; bufferA = ""; indexA = 0; } else if (mode !== "alpha" && indexRGB === 33) { paramLen = parseInt(bufferRGB.slice(0, -1), 2); readingParamLen = false; readingParam = true; bufferRGB = bufferRGB.slice(-1); indexRGB = 1; } } else if (readingParam) { if (mode === "alpha" && indexA === paramLen) { binaryData = bufferA; readEnd = true; break; } else if (mode !== "alpha" && indexRGB >= paramLen) { const diff = paramLen - indexRGB; if (diff < 0) { bufferRGB = bufferRGB.slice(0, diff); } binaryData = bufferRGB; readEnd = true; break; } } else { readEnd = true; break; } } if (readEnd) { break; } } if (sigConfirmed && binaryData) { const byteData = new Uint8Array(binaryData.match(/\d{8}/g).map(b => parseInt(b, 2))); let decodedData; if (compressed) { decodedData = pako.inflate(byteData, { to: "string" }); } else { decodedData = new TextDecoder().decode(byteData); } geninfo = decodedData; } return geninfo; } function extractChunks(data) { if (data[0] !== 0x89) throw new Error("Invalid .png file header"); if (data[1] !== 0x50) throw new Error("Invalid .png file header"); if (data[2] !== 0x4E) throw new Error("Invalid .png file header"); if (data[3] !== 0x47) throw new Error("Invalid .png file header"); if (data[4] !== 0x0D) throw new Error("Invalid .png file header"); if (data[5] !== 0x0A) throw new Error("Invalid .png file header"); if (data[6] !== 0x1A) throw new Error("Invalid .png file header"); if (data[7] !== 0x0A) throw new Error("Invalid .png file header"); let ended = false; let chunks = []; let idx = 8; while (idx < data.length) { uint8[3] = data[idx++]; uint8[2] = data[idx++]; uint8[1] = data[idx++]; uint8[0] = data[idx++]; let length = uint32[0] + 4; let chunk = new Uint8Array(length); chunk[0] = data[idx++]; chunk[1] = data[idx++]; chunk[2] = data[idx++]; chunk[3] = data[idx++]; let name = ( String.fromCharCode(chunk[0]) + String.fromCharCode(chunk[1]) + String.fromCharCode(chunk[2]) + String.fromCharCode(chunk[3]) ); if (!chunks.length && name !== "IHDR") { throw new Error("IHDR header missing"); } if (name === "IEND") { ended = true; chunks.push({ name: name, data: new Uint8Array(0) }); break; } for (let i = 4; i < length; i++) { chunk[i] = data[idx++]; } uint8[3] = data[idx++]; uint8[2] = data[idx++]; uint8[1] = data[idx++]; uint8[0] = data[idx++]; let chunkData = new Uint8Array(chunk.buffer.slice(4)); chunks.push({ name: name, data: chunkData }); } return chunks; } function textDecode(data, name = "tEXt") { if (data.data && data.name) { data = data.data; } let naming = true; let keywordBytes = []; let textBytes = []; for (let i = 0; i < data.length; i++) { const code = data[i]; if (naming) { if (code) { keywordBytes.push(code); } else { naming = false; } } else { if (code) { textBytes.push(code); } } } const decoder = new TextDecoder(name === "tEXt" ? "latin1" : "utf8"); return { keyword: decoder.decode(new Uint8Array(keywordBytes)), text: decoder.decode(new Uint8Array(textBytes)), }; } function readUint32(uint8array, offset) { let byte1, byte2, byte3, byte4; byte1 = uint8array[offset++]; byte2 = uint8array[offset++]; byte3 = uint8array[offset++]; byte4 = uint8array[offset]; return 0 | (byte1 << 24) | (byte2 << 16) | (byte3 << 8) | byte4; } function readMetadata(buffer) { let result = {}; const chunks = extractChunks(buffer); chunks.forEach(chunk => { switch (chunk.name) { case "tEXt": case "iTXt": if (!result.tEXt) { result.tEXt = {}; } let textChunk = textDecode(chunk.data, chunk.name); result.tEXt[textChunk.keyword] = textChunk.text; break; case "pHYs": result.pHYs = { "x": readUint32(chunk.data, 0), "y": readUint32(chunk.data, 4), "unit": chunk.data[8], }; break; default: result[chunk.name] = true; } }); return result; } function parseNaiMetadata(metadata) { try { const parsed = JSON.parse(metadata); return JSON.stringify(parsed, null, 2); } catch (e) { return metadata; } } function formatMetadataOutput(text) { try { const parsed = JSON.parse(text); return JSON.stringify(parsed, null, 2); } catch (e) { return text; } } function readPromptMetadata(url) { return new Promise((resolve, reject) => { if (!url) return reject("No URL provided"); GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", onload: async (response) => { if (response.status !== 200 || !response.response) { reject("Failed to fetch image data, status: " + response.status); return; } const blob = response.response; try { const arrayBuffer = await blob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); try { const exifMeta = ExifReader.load(arrayBuffer); if (exifMeta && exifMeta.UserComment) { const value = exifMeta.UserComment.value; let rawText; if (Array.isArray(value)) { const cleanBytes = value.slice(8).filter(b => b !== 0); rawText = new TextDecoder("utf-8").decode(new Uint8Array(cleanBytes)); } else if (typeof value === "string") { rawText = value; } if (rawText && rawText.trim()) { return resolve(formatMetadataOutput(rawText)); } } } catch (e) {} try { const pngMeta = readMetadata(uint8Array); if (pngMeta && pngMeta.tEXt) { let found = null; if (pngMeta.tEXt.Comment && pngMeta.tEXt.Source) { found = "Version: " + pngMeta.tEXt.Source + "\n\n" + parseNaiMetadata(pngMeta.tEXt.Comment); } else if (pngMeta.tEXt.Dream) { found = `${pngMeta.tEXt.Dream} ${pngMeta.tEXt['sd-metadata'] || ''}`; } else if (pngMeta.tEXt.parameters) { found = pngMeta.tEXt.parameters; } else if (pngMeta.tEXt.prompt || pngMeta.tEXt.workflow) { found = `prompt\n \n${pngMeta.tEXt.prompt}\n \nworkflow\n \n${pngMeta.tEXt.workflow}`; } else if (pngMeta.tEXt.chara) { try { let charaDef = atob(pngMeta.tEXt.chara); let charaDefJson = JSON.parse(charaDef); if (charaDefJson && ['name', 'description', 'mes_example', 'first_mes'].every(val => Object.keys(charaDefJson).includes(val))) { found = `Name: ${charaDefJson['name']}\n \nDescription: ${charaDefJson['description']}\n \nMessage example: ${charaDefJson['mes_example']}\n \nFirst message: ${charaDefJson['first_mes']}`; } } catch (_) {} } if (found) { return resolve(found); } } } catch (e) {} try { const image = await loadImage(blob); const stealth = readInfoFromImageStealth(image); if (stealth) { try { let stealthMetadata = JSON.parse(stealth); if (stealthMetadata?.Comment) { return resolve('Version: ' + stealthMetadata?.Source + '\n\n' + parseNaiMetadata(stealthMetadata['Comment'])); } else if (stealthMetadata?.parameters) { return resolve(stealthMetadata.parameters); } else if (stealthMetadata?.prompt || stealthMetadata?.workflow) { return resolve(`prompt\n \n${stealthMetadata?.prompt}\n \nworkflow\n \n${stealthMetadata?.workflow}`); } } catch (err) { return resolve(stealth); } } } catch (e) {} reject("No metadata found in this image."); } catch (err) { reject("Error processing image metadata: " + err); } }, onerror: () => { reject("Failed to fetch image: network error"); } }); }); } // ═══════════════════════════════════════════════════════════════ // MULTI-SITE SCRAPING // ═══════════════════════════════════════════════════════════════ function getPosts(websiteUrl, doc) { switch (websiteUrl) { case "warosu.org": return doc.querySelectorAll(".comment, .highlight"); case "archived.moe": case "archive.palanq.win": case "archive.4plebs.org": case "desuarchive.org": case "thebarchive.com": case "archiveofsins.com": return doc.querySelectorAll(".post, .thread"); case "boards.4chan.org": case "boards.4channel.org": default: return doc.querySelectorAll(".postContainer"); } } function getDocument(thread, currentUrl) { if (thread === currentUrl) return Promise.resolve(document); return fetch(thread) .then((r) => r.text()) .then((html) => new DOMParser().parseFromString(html, "text/html")); } function parsePostData(post, websiteUrl, thread) { const data = { mediaLink: null, fileName: null, thumbnailUrl: null, comment: null, board: null, threadID: null, postID: null, postURL: null, }; switch (websiteUrl) { case "warosu.org": { const thumb = post.querySelector(".thumb"); data.fileName = post.querySelector(".fileinfo")?.innerText.split(", ")[2]; data.thumbnailUrl = thumb?.src; data.mediaLink = thumb?.parentNode.href; data.comment = post.querySelector("blockquote"); const tidMatch = thread.match(/thread\/(\d+)/); if (tidMatch) { data.threadID = tidMatch[1]; } else { const jsLink = post.querySelector(".js"); data.threadID = jsLink?.href.match(/thread\/(\d+)/)?.[1] || ""; } data.postID = post.id.replace("pc", "").replace("p", ""); break; } case "archived.moe": case "archive.palanq.win": case "archive.4plebs.org": case "desuarchive.org": case "thebarchive.com": case "archiveofsins.com": data.thumbnailUrl = post.querySelector(".post_image")?.src; data.mediaLink = post.querySelector(".thread_image_link")?.href; data.fileName = post.querySelector(".post_file_filename")?.title; data.comment = post.querySelector(".text"); data.threadID = post.querySelector(".post_data > a")?.href.match(/thread\/(\d+)/)?.[1] || ""; data.postID = post.id; break; case "boards.4chan.org": case "boards.4channel.org": default: { if (post.querySelector(".fileText")) { if (post.querySelector(".download-button")) { // 4chanX compatibility const dlBtn = post.querySelector(".download-button"); data.mediaLink = dlBtn.href; data.fileName = dlBtn.download; } else { let fileTextEl, linkEl; if (post.classList.contains("opContainer")) { linkEl = post.querySelector(".fileText a"); fileTextEl = linkEl; } else { fileTextEl = post.querySelector(".fileText"); linkEl = fileTextEl.querySelector("a"); } if (fileTextEl.title) { data.fileName = fileTextEl.title; } else if (linkEl.title) { data.fileName = linkEl.title; } else { data.fileName = linkEl.innerText; } data.mediaLink = linkEl.href; } data.thumbnailUrl = post.querySelector(".fileThumb img")?.src; } data.comment = post.querySelector(".postMessage"); data.threadID = thread.match(/thread\/(\d+)/)?.[1] || ""; data.postID = post.id.replace("pc", "").replace("p", ""); break; } } // Clean post URL let postURL = thread; if (thread.includes("#")) { postURL = thread.replace(/#p\d+/, "").replace(/#pc\d+/, ""); } data.postURL = postURL; data.board = thread.match(/\/\/[^/]+\/([^/]+)/)?.[1] || ""; return data; } // ═══════════════════════════════════════════════════════════════ // DRAG & DROP (shared setup) // ═══════════════════════════════════════════════════════════════ function setupCellDrag(cell, gridContainer) { cell.draggable = true; cell.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/plain", String([...gridContainer.children].indexOf(cell))); e.dataTransfer.dropEffect = "move"; cell.classList.add("gc-cell--dragging"); }); cell.addEventListener("dragend", () => { cell.classList.remove("gc-cell--dragging"); }); cell.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; cell.classList.add("gc-cell--dragover"); }); cell.addEventListener("dragleave", () => { cell.classList.remove("gc-cell--dragover"); }); cell.addEventListener("drop", (e) => { e.preventDefault(); cell.classList.remove("gc-cell--dragover"); const draggedIdx = parseInt(e.dataTransfer.getData("text/plain"), 10); const children = [...gridContainer.children]; const draggedCell = children[draggedIdx]; if (draggedCell && draggedCell !== cell) { const dropIdx = children.indexOf(cell); if (draggedIdx < dropIdx) { gridContainer.insertBefore(draggedCell, cell.nextSibling); } else { gridContainer.insertBefore(draggedCell, cell); } } }); } // ═══════════════════════════════════════════════════════════════ // AUDIO HELPERS // ═══════════════════════════════════════════════════════════════ function buildAudioSrc(soundMatch) { let src = decodeURIComponent( soundMatch[1].startsWith("http") ? soundMatch[1] : `https://${soundMatch[1]}` ); if (cfg("Switch_Catbox_To_Pixstash_For_Soundposts")) { src = src.replace("catbox.moe", "pixstash.moe"); } return src; } function syncVideoAudio(video, audio) { video.onplay = () => { if (!audio.src) audio.src = audio.dataset.src; audio.play(); }; video.onpause = () => audio.pause(); let lastT = 0; video.addEventListener("timeupdate", () => { if (Math.abs(video.currentTime - lastT) >= 2) { audio.currentTime = video.currentTime; } lastT = video.currentTime; }); } // ═══════════════════════════════════════════════════════════════ // BOOKMARK BUTTON FACTORY // ═══════════════════════════════════════════════════════════════ function createBookmarkBtn(url, postData, gridContainer, refreshBookmarks) { const isBookmarkedVal = isBookmarked(url); const b = btn(isBookmarkedVal ? "★" : "☆", null, { classes: ["gc-btn--icon", "gc-btn--bookmark", isBookmarkedVal ? "gc-btn--bookmark-active" : ""], title: isBookmarkedVal ? "Remove Bookmark" : "Bookmark Post", }); function updateVisual(active) { b.textContent = active ? "★" : "☆"; b.title = active ? "Remove Bookmark" : "Bookmark Post"; b.classList.toggle("gc-btn--bookmark-active", active); } b.addEventListener("click", (e) => { e.stopPropagation(); const nowBM = toggleBookmark(postData); updateVisual(nowBM); if (state.currentView === "bookmarks" && refreshBookmarks) { refreshBookmarks(); } }); // Expose update method for external sync b._updateVisual = updateVisual; return b; } // ═══════════════════════════════════════════════════════════════ // MAIN: loadButton (Gallery init) // ═══════════════════════════════════════════════════════════════ function loadButton() { const isArchivePage = window.location.pathname.includes("/archive"); // ── Page button ── const pageBtn = h("button", { a: { id: "openImageGallery" }, txt: "Open Image Gallery", s: { visibility: cfg("Hide_Gallery_Button") ? "hidden" : "visible", }, }); let gallery, gridContainer; let bookmarksBtnEl, settingsBtnEl, mediaCountEl; let modeBarEl, searchBarEl, searchInputEl; // ──────────────────────────────────────────────── // BUILD GRID CELL (unified for regular + external) // ──────────────────────────────────────────────── function buildCell(mediaLink, thumbnailUrl, fileName, commentText, soundLink, isVideo, isImage, postURL, board, threadID, postID, isExternal, soundUrl) { const maxH = cfg("Grid_Cell_Max_Height"); const cell = h("div", { c: ["gc-cell", isExternal ? "gc-cell--external" : ""], a: { postURL }, }); cell._originalIdx = state.mediaCount; setupCellDrag(cell, gridContainer); const mediaWrap = h("div", { c: "gc-media-wrap" }); const controlBar = h("div", { c: "gc-controls" }); let encodedSoundPostLink = null; if (soundLink && board && threadID && postID) { encodedSoundPostLink = `https://4chan.mahdeensky.top/${board}/thread/${threadID}/${postID}`; } if (isVideo) { // -- Video cell -- const isCatbox = /catbox\.moe|pixstash\.moe/.test(mediaLink); const thumbTag = isCatbox ? "video" : "img"; const videoThumb = h(thumbTag, { a: { src: thumbnailUrl, alt: "Video Thumbnail", loading: "lazy" }, s: { width: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer" }, }); const video = h("video", { a: { "data-src": mediaLink, controls: "true", fileName, board, threadID, postID, title: commentText, }, s: { maxWidth: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer", display: "none" }, }); // Video badge const badge = h("div", { c: ["gc-video-badge", (soundLink || soundUrl) ? "gc-sound-badge" : ""], html: (soundLink || soundUrl) ? "♫ SOUND" : "▶ VIDEO" }); // Thumbnail → video swap on hover videoThumb.addEventListener("mouseenter", () => { if (!video.src) video.src = video.dataset.src; videoThumb.style.display = "none"; video.style.display = "block"; badge.style.display = "none"; }); // Auto-play logic for non-sound videos if (!soundLink && !soundUrl) { if (state.autoPlayWebms) { video.addEventListener("canplaythrough", () => { video.play(); video.loop = true; }, { once: true }); } else if (cfg("Play_Webms_On_Hover")) { video.addEventListener("mouseenter", () => { if (!video.src) video.src = video.dataset.src; video.play(); }); video.addEventListener("mouseleave", () => video.pause()); } } mediaWrap.append(badge, videoThumb, video); // Soundpost audio if (soundLink || soundUrl) { const audioSrc = soundUrl || buildAudioSrc(soundLink); const audio = h("audio", { a: { "data-src": audioSrc, encodedSoundPostLink } }); videoThumb.addEventListener("mouseenter", () => { if (!audio.src) audio.src = audio.dataset.src; }); syncVideoAudio(video, audio); mediaWrap.appendChild(audio); // Reset button controlBar.appendChild(btn("Reset", () => { video.currentTime = 0; audio.currentTime = 0; })); } // Open button controlBar.appendChild(btn("Open", () => window.open(mediaLink, "_blank"))); } else if (isImage) { // -- Image cell -- if (state.mode !== "all" && !soundLink && !soundUrl) return null; // skip non-sound images in webm mode let imgSrc = thumbnailUrl; if (cfg("Load_High_Res_Images_By_Default")) imgSrc = mediaLink; if (mediaLink.match(/\.gif$/i)) { imgSrc = cfg("Strictly_Load_GIFs_As_Thumbnails_On_Hover") ? thumbnailUrl : mediaLink; } const image = h("img", { a: { src: convertToCatboxWebp(imgSrc), fileName, actualSrc: mediaLink, thumbnailUrl, board, threadID, postID, loading: "lazy", title: commentText, }, s: { maxWidth: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer" }, }); // GIF hover swap (fixed: actually swap between thumbnail and full) if (cfg("Strictly_Load_GIFs_As_Thumbnails_On_Hover") && mediaLink.match(/\.gif$/i)) { image.addEventListener("mouseenter", () => { image.src = mediaLink; }); image.addEventListener("mouseleave", () => { image.src = thumbnailUrl; }); } mediaWrap.appendChild(image); // Soundpost audio for images if (soundLink || soundUrl) { const audioSrc = soundUrl || buildAudioSrc(soundLink); const audio = h("audio", { a: { "data-src": audioSrc, loop: "true", encodedSoundPostLink } }); mediaWrap.appendChild(audio); image.addEventListener("mouseenter", () => { if (!audio.src) audio.src = audio.dataset.src; audio.play(); }); image.addEventListener("mouseleave", () => audio.pause()); // Badge const badge = h("div", { c: ["gc-video-badge", "gc-sound-badge"], html: "♫ SOUND" }); mediaWrap.appendChild(badge); controlBar.appendChild(btn("Play/Pause", () => { audio.paused ? audio.play() : audio.pause(); })); } } else { return null; } // View Post button controlBar.appendChild(btn("View Post", () => { state.gallerySize = { w: gridContainer.offsetWidth, h: gridContainer.offsetHeight }; state.lastScrollPos = gridContainer.scrollTop; window.location.href = postURL; gallery.style.display = "none"; })); // Bookmark const soundLinkStr = soundLink ? (Array.isArray(soundLink) ? soundLink[1] : soundLink) : null; const bmBtn = createBookmarkBtn(mediaLink, { url: mediaLink, thumbnailUrl, commentText, board, threadID, postID, postURL, isExternal: !!isExternal, soundLink: soundLinkStr, soundUrl: soundUrl || null, }, gridContainer, () => showBookmarksGrid()); controlBar.appendChild(bmBtn); cell.appendChild(mediaWrap); cell.appendChild(controlBar); return cell; } // ──────────────────────────────────────────────── // BOOKMARKS GRID // ──────────────────────────────────────────────── function showBookmarksGrid() { gridContainer.innerHTML = ""; const list = Object.values(state.bookmarks); if (!list.length) { gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No bookmarks saved yet." })); return; } list.forEach((bm, idx) => { const isVid = /\.(webm|mp4)$/i.test(bm.url); const isImg = /\.(jpg|jpeg|png|gif)$/i.test(bm.url); const soundLink = bm.soundLink ? [null, bm.soundLink] : null; const cell = buildCell( bm.url, bm.thumbnailUrl || bm.url, bm.url.split("/").pop(), bm.commentText || "", soundLink, isVid, isImg, bm.postURL, bm.board, bm.threadID, bm.postID, bm.isExternal, bm.soundUrl ); if (!cell) return; cell._originalIdx = idx; const info = h("div", { c: "gc-bm-info" }); const meta = h("div", { c: "gc-bm-meta" }); meta.appendChild(h("span", { txt: `ID: ${bm.postID || "External"} | Thread: ${bm.threadID || "N/A"}` })); const ts = bm.timestamp || ""; const tsDisplay = ts.includes("T") ? new Date(ts).toLocaleDateString() : ts.split(",")[0]; const timeSpan = h("span", { txt: tsDisplay }); timeSpan.title = `Bookmarked at ${ts}`; meta.appendChild(timeSpan); info.appendChild(meta); if (bm.commentText) { const cmnt = h("div", { c: "gc-bm-comment", txt: bm.commentText }); cmnt.title = bm.commentText; info.appendChild(cmnt); } cell.appendChild(info); gridContainer.appendChild(cell); }); // Apply current search query if any if (searchInputEl && searchInputEl.value) { filterGrid(searchInputEl.value); } } function filterGrid(query) { const q = query.toLowerCase().trim(); const cells = [...gridContainer.children].filter( cell => cell.classList.contains("gc-cell") ); if (!q) { cells.sort((a, b) => (a._originalIdx || 0) - (b._originalIdx || 0)); cells.forEach(cell => { cell.style.display = ""; gridContainer.appendChild(cell); }); updateMediaCount(); return; } cells.forEach(cell => { const media = cell.querySelector("img, video"); if (!media) { cell._isMatch = false; return; } const fileName = (media.getAttribute("fileName") || "").toLowerCase(); const comment = (media.title || "").toLowerCase(); const mediaSrc = (media.getAttribute("actualSrc") || media.dataset.src || media.src || "").toLowerCase(); cell._isMatch = fileName.includes(q) || comment.includes(q) || mediaSrc.includes(q); }); cells.sort((a, b) => { if (a._isMatch && !b._isMatch) return -1; if (!a._isMatch && b._isMatch) return 1; return (a._originalIdx || 0) - (b._originalIdx || 0); }); let matchCount = 0; cells.forEach(cell => { gridContainer.appendChild(cell); if (cell._isMatch) { cell.style.display = ""; matchCount++; } else { cell.style.display = "none"; } }); if (mediaCountEl) { mediaCountEl.textContent = `${matchCount} of ${cells.length} media`; } } // ──────────────────────────────────────────────── // ZOOM MODE // ──────────────────────────────────────────────── function enterZoomMode(cell) { if (state.zoomActive) return; state.zoomActive = true; // Hide top buttons & search bar if (settingsBtnEl) settingsBtnEl.style.display = "none"; if (bookmarksBtnEl) bookmarksBtnEl.style.display = "none"; if (modeBarEl) modeBarEl.style.display = "none"; if (searchBarEl) searchBarEl.style.display = "none"; // Overlay const bg = h("div", { c: "gc-zoom" }); // Arrow buttons if (cfg("Show_Arrow_Buttons_In_Zoom_Mode")) { const arrowL = h("button", { c: ["gc-zoom-arrow", "gc-zoom-arrow--left"], txt: "◀" }); const arrowR = h("button", { c: ["gc-zoom-arrow", "gc-zoom-arrow--right"], txt: "▶" }); arrowL.addEventListener("click", () => navigate("left")); arrowR.addEventListener("click", () => navigate("right")); bg.append(arrowL, arrowR); } // Bottom controls const bottomCtrl = h("div", { c: "gc-zoom-controls" }); bg.appendChild(bottomCtrl); // Search buttons group const searchGrp = h("div", { c: "gc-zoom-btn-group" }); searchGrp.appendChild(btn("SauceNAO", () => { window.open(`https://saucenao.com/search.php?url=${encodeURIComponent(searchGrp.dataset.mediaLink)}`); })); searchGrp.appendChild(btn("Google Lens", () => { window.open(`https://lens.google.com/uploadbyurl?url=${encodeURIComponent(searchGrp.dataset.mediaLink)}`); })); searchGrp.appendChild(btn("Yandex", () => { window.open(`https://yandex.com/images/search?rpt=imageview&url=${encodeURIComponent(searchGrp.dataset.mediaLink)}`); })); function showMetadataModal(imageUrl) { const overlay = h("div", { c: "gc-meta-overlay" }); const box = h("div", { c: "gc-meta-box" }); const title = h("h2", { c: "gc-meta-title" }); title.appendChild(h("span", { txt: "Prompt / Generation Metadata" })); const closeBtn = btn("×", () => overlay.remove(), { classes: ["gc-btn--icon"], attrs: { style: "font-size: 20px; line-height: 1; padding: 4px 8px;" } }); title.appendChild(closeBtn); const content = h("div", { c: "gc-meta-content", txt: "Fetching metadata from original image..." }); box.append(title, content); overlay.appendChild(box); const actions = h("div", { s: { display: "flex", justifyContent: "flex-end", gap: "10px" } }); const copyBtn = btn("Copy Metadata", () => { navigator.clipboard.writeText(content.textContent) .then(() => { copyBtn.textContent = "Copied!"; setTimeout(() => { copyBtn.textContent = "Copy Metadata"; }, 2000); }) .catch(err => console.error("Failed to copy:", err)); }, { classes: ["gc-btn--success"] }); copyBtn.style.display = "none"; actions.appendChild(copyBtn); box.appendChild(actions); gallery.appendChild(overlay); readPromptMetadata(imageUrl) .then((meta) => { content.textContent = meta; copyBtn.style.display = "inline-flex"; }) .catch((err) => { content.textContent = String(err); }); const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); } const metaBtn = btn("Metadata", () => { showMetadataModal(searchGrp.dataset.mediaLink); }); searchGrp.appendChild(metaBtn); bottomCtrl.appendChild(searchGrp); // Download/nav group const dlGrp = h("div", { c: "gc-zoom-btn-group" }); const viewPostLink = btnLink("View Post"); dlGrp.appendChild(viewPostLink); const dlBtn = btnLink("Download", { onClick: () => downloadMedia(dlBtn.href, dlBtn.download), }); dlGrp.appendChild(dlBtn); // Zoom bookmark button const zoomBmBtn = btn("☆", null, { classes: ["gc-btn--icon", "gc-btn--bookmark"], title: "Bookmark Post" }); zoomBmBtn.addEventListener("click", () => { const src = searchGrp.dataset.mediaLink; if (!src) return; const media = currentCell.querySelector("img, video"); const nowBM = toggleBookmark({ url: src, thumbnailUrl: src, commentText: titlePill.textContent, board: media?.getAttribute("board") || "", threadID: media?.getAttribute("threadID") || "", postID: media?.getAttribute("postID") || "", postURL: currentCell.getAttribute("postURL") || window.location.href, isExternal: currentCell.classList.contains("gc-cell--external"), }); updateZoomBm(nowBM); // Sync grid cell bookmark button const cells = [...gridContainer.children]; const matchCell = cells.find((c) => { const m = c.querySelector("img, video"); if (!m) return false; const asrc = m.getAttribute("actualSrc") || m.dataset.src || m.src; if (asrc === src) return true; const keyA = findBookmarkKey(asrc); const keyB = findBookmarkKey(src); return keyA && keyB && keyA === keyB; }); if (matchCell) { const starBtn = matchCell.querySelector(".gc-btn--bookmark"); if (starBtn?._updateVisual) starBtn._updateVisual(nowBM); } if (state.currentView === "bookmarks") showBookmarksGrid(); }); dlGrp.appendChild(zoomBmBtn); const audioDlBtn = btnLink("Download Audio", { onClick: () => downloadMedia(audioDlBtn.href, audioDlBtn.download), }); dlGrp.appendChild(audioDlBtn); const encodedBtn = btnLink("Download Encoded Soundpost"); encodedBtn.target = "_blank"; dlGrp.appendChild(encodedBtn); bottomCtrl.appendChild(dlGrp); function updateZoomBm(isBookmarked) { zoomBmBtn.textContent = isBookmarked ? "★" : "☆"; zoomBmBtn.title = isBookmarked ? "Remove Bookmark" : "Bookmark Post"; zoomBmBtn.classList.toggle("gc-btn--bookmark-active", isBookmarked); } // Pills const indexPill = h("div", { c: ["gc-pill", "gc-pill--index"] }); const titlePill = h("div", { c: ["gc-pill", "gc-pill--title"] }); bg.append(indexPill, titlePill); let currentCell = cell; function showZoomedCell(targetCell) { // Cleanup old zoomed media const oldVid = gallery.querySelector("#gcZoomVideo"); if (oldVid) { oldVid.querySelector("audio")?.pause(); oldVid.pause(); oldVid.remove(); } const oldImg = gallery.querySelector("#gcZoomImage"); if (oldImg) { oldImg.querySelector("audio")?.pause(); if (oldImg._cleanupDrag) oldImg._cleanupDrag(); oldImg.remove(); } const video = targetCell.querySelector("video"); const img = targetCell.querySelector("img"); if (video) { if (metaBtn) metaBtn.style.display = "none"; const z = video.cloneNode(true); z.id = "gcZoomVideo"; z.removeAttribute("style"); z.classList.add("gc-zoom-media"); z.src = z.dataset.src || video.src; z.controls = true; z.preload = "auto"; gallery.appendChild(z); let audioEl = targetCell.querySelector("audio"); if (audioEl) { audioEl = audioEl.cloneNode(true); syncVideoAudio(z, audioEl); z.appendChild(audioEl); } } else if (img) { if (metaBtn) metaBtn.style.display = "inline-flex"; const z = img.cloneNode(true); z.id = "gcZoomImage"; z.removeAttribute("style"); z.classList.add("gc-zoom-media"); z.src = z.getAttribute("actualSrc") || img.src; gallery.appendChild(z); // Scroll zoom and pan state let zoomScale = 1.0; let panX = 0; let panY = 0; let isDragging = false; let hasDragged = false; let startX = 0; let startY = 0; const updateTransform = () => { z.style.transform = `translate(calc(-50% + ${panX}px), calc(-50% + ${panY}px)) scale(${zoomScale})`; }; z.addEventListener("wheel", (e) => { e.preventDefault(); const delta = e.deltaY < 0 ? 1.15 : 1 / 1.15; const nextScale = zoomScale * delta; if (nextScale >= 0.5 && nextScale <= 10) { const mx = e.clientX - window.innerWidth / 2; const my = e.clientY - window.innerHeight / 2; panX = mx - (mx - panX) * delta; panY = my - (my - panY) * delta; zoomScale = nextScale; if (zoomScale <= 1.01) { panX = 0; panY = 0; z.style.cursor = "pointer"; } else { z.style.cursor = "grab"; } updateTransform(); } }, { passive: false }); const onMouseMove = (e) => { if (!isDragging) return; const newPanX = e.clientX - startX; const newPanY = e.clientY - startY; if (Math.abs(newPanX - panX) > 2 || Math.abs(newPanY - panY) > 2) { hasDragged = true; } panX = newPanX; panY = newPanY; updateTransform(); }; const onMouseUp = () => { if (isDragging) { isDragging = false; z.style.cursor = zoomScale > 1.0 ? "grab" : "pointer"; window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); } }; z.addEventListener("mousedown", (e) => { if (zoomScale <= 1.0) return; e.preventDefault(); isDragging = true; hasDragged = false; startX = e.clientX - panX; startY = e.clientY - panY; z.style.cursor = "grabbing"; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); }); z.addEventListener("click", (e) => { e.stopPropagation(); if (hasDragged) { hasDragged = false; return; } if (zoomScale > 1.0) { zoomScale = 1.0; panX = 0; panY = 0; z.style.cursor = "pointer"; updateTransform(); } else { closeZoom(); } }); z._cleanupDrag = () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; let audioEl = targetCell.querySelector("audio"); if (audioEl) { audioEl = audioEl.cloneNode(true); z.appendChild(audioEl); z.addEventListener("mouseenter", () => { if (!audioEl.src) audioEl.src = audioEl.dataset.src; audioEl.play(); }); z.addEventListener("mouseleave", () => audioEl.pause()); } } // Update controls const media = video || img; if (media) { const src = media.getAttribute("actualSrc") || media.dataset.src || media.src; searchGrp.dataset.mediaLink = src; const visibleCells = [...targetCell.parentNode.children].filter( c => c.classList.contains("gc-cell") && c.style.display !== "none" ); const idx = visibleCells.indexOf(targetCell) + 1; const total = visibleCells.length; indexPill.textContent = `${idx} / ${total}`; const fName = media.getAttribute("fileName") || src.split("/").pop(); titlePill.textContent = fName; const bd = media.getAttribute("board"); const tid = media.getAttribute("threadID"); const pid = media.getAttribute("postID"); if (bd && tid && pid) { viewPostLink.href = `https://boards.4chan.org/${bd}/thread/${tid}#p${pid}`; } else { viewPostLink.href = targetCell.getAttribute("postURL") || window.location.href; } viewPostLink.style.display = "inline-flex"; dlBtn.href = src; dlBtn.download = fName; const cellAudio = targetCell.querySelector("audio"); if (cellAudio) { const aSrc = cellAudio.dataset.src || cellAudio.src; audioDlBtn.href = aSrc; audioDlBtn.download = aSrc.split("/").pop(); audioDlBtn.style.display = "inline-flex"; const encLink = cellAudio.getAttribute("encodedSoundPostLink"); if (encLink) { encodedBtn.href = encLink; encodedBtn.style.display = "inline-flex"; } else { encodedBtn.style.display = "none"; } } else { audioDlBtn.style.display = "none"; encodedBtn.style.display = "none"; } updateZoomBm(isBookmarked(src)); } } function navigate(dir) { const children = [...gridContainer.children].filter( cell => cell.classList.contains("gc-cell") && cell.style.display !== "none" ); if (!children.length) return; let idx = children.indexOf(currentCell); if (dir === "left") { idx = idx <= 0 ? children.length - 1 : idx - 1; } else { idx = idx >= children.length - 1 ? 0 : idx + 1; } currentCell = children[idx]; showZoomedCell(currentCell); } function closeZoom() { const zv = gallery.querySelector("#gcZoomVideo"); if (zv) { zv.pause(); zv.querySelector("audio")?.pause(); zv.remove(); } const zi = gallery.querySelector("#gcZoomImage"); if (zi) { zi.querySelector("audio")?.pause(); if (zi._cleanupDrag) zi._cleanupDrag(); zi.remove(); } bg.remove(); document.removeEventListener("keydown", zoomKeyHandler); state.zoomActive = false; if (settingsBtnEl) settingsBtnEl.style.display = ""; if (bookmarksBtnEl) bookmarksBtnEl.style.display = ""; if (state.currentView !== "bookmarks") { if (modeBarEl) modeBarEl.style.display = ""; } if (searchBarEl) searchBarEl.style.display = ""; } function zoomKeyHandler(e) { if (e.key === "ArrowLeft") navigate("left"); else if (e.key === "ArrowRight") navigate("right"); else if (e.key === "Escape") closeZoom(); } document.addEventListener("keydown", zoomKeyHandler); bg.addEventListener("click", (e) => { if (e.target === bg) closeZoom(); }); gallery.appendChild(bg); showZoomedCell(currentCell); } // ──────────────────────────────────────────────── // SETTINGS MODAL // ──────────────────────────────────────────────── function openSettings() { const overlay = h("div", { c: "gc-settings-overlay" }); const box = h("div", { c: "gc-settings-box" }); box.appendChild(h("h2", { c: "gc-settings-title", txt: "Settings" })); const list = h("ul", { c: "gc-settings-list", a: { id: "gcSettingsList" } }); for (const key in state.settings) { if (!(key in defaultSettings)) continue; const item = h("li", { c: "gc-settings-item" }); const label = h("label", { c: "gc-settings-label", txt: key.replace(/_/g, " ") }); label.title = state.settings[key].info; item.appendChild(label); const valType = typeof defaultSettings[key].value; const input = h("input", { c: "gc-settings-input" }); input.dataset.settingKey = key; if (valType === "boolean") { input.type = "checkbox"; input.checked = state.settings[key].value; } else if (valType === "number") { input.type = "number"; input.value = state.settings[key].value; } else { input.type = "text"; input.value = state.settings[key].value; } item.appendChild(input); list.appendChild(item); } const actions = h("div", { c: "gc-settings-actions" }); actions.appendChild(btn("Save", () => { const newSettings = {}; for (const k in defaultSettings) newSettings[k] = { ...defaultSettings[k] }; list.querySelectorAll("input").forEach((inp) => { const k = inp.dataset.settingKey; if (k && k in defaultSettings) { const t = typeof defaultSettings[k].value; if (t === "boolean") { newSettings[k].value = inp.checked; } else if (t === "number") { newSettings[k].value = parseInt(inp.value, 10) || defaultSettings[k].value; } else { newSettings[k].value = inp.value; } } }); saveSettings(newSettings); state.settings = newSettings; overlay.remove(); // Update grid in-place if (gridContainer) { gridContainer.style.gridTemplateColumns = `repeat(${cfg("Grid_Columns")}, 1fr)`; // Reload to apply cell height and other changes gridContainer.innerHTML = ""; loadPosts(); } }, { classes: ["gc-btn--success", "gc-btn--large"] })); actions.appendChild(btn("Close", () => overlay.remove(), { classes: ["gc-btn--large"] })); box.append(list, actions); overlay.appendChild(box); // Close on Escape const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); // Close on backdrop click overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); gallery.appendChild(overlay); } // ──────────────────────────────────────────────── // CHANGELOG POPUP (v4.0 Highlights) // ──────────────────────────────────────────────── function checkChangelog() { if (getGMValue("hasSeenChangelog_4_0", false)) return; const overlay = h("div", { c: "gc-changelog-overlay" }); const box = h("div", { c: "gc-changelog-box" }); box.appendChild(h("h2", { c: "gc-changelog-header", txt: "What's New in Version 4.0!" })); const list = h("ul", { c: "gc-changelog-list" }); const features = [ { title: "★ Bookmarking & Catbox Archival", desc: "Star any media in the cell controls to save it persistently. Favorites are automatically archived/backed up to Catbox to prevent link rot." }, { title: "📁 Dedicated Bookmarks Grid", desc: "Toggle between the gallery and your personal bookmarks grid containing full metadata (original thread post link, date, comment, and search)." }, { title: "🔍 Real-time Search & Filter", desc: "A new search bar in the header lets you instantly filter the media grid by filenames, comments, or URLs." }, { title: "🤖 AI Image Metadata Viewer", desc: "Examine embedded AI generation parameters (prompts, seeds, CFG, models) from PNG images (supports Stable Diffusion, NovelAI, ComfyUI)." }, { title: "🔎 Smooth Pan & Zoom Controls", desc: "Use your mouse scroll wheel to zoom in/out on images and click-drag to pan around in zoom mode." }, { title: "⚙️ Cross-Site Settings", desc: "Settings are now saved securely and persist across all board sections and archive sites using GM_getValue." }, { title: "⌨️ Keyboard Navigation Enhancements", desc: "Close the gallery or zoom view instantly with the Escape key." } ]; features.forEach(f => { const item = h("li", { c: "gc-changelog-item", ch: [ h("span", { c: "gc-changelog-title", txt: f.title }), h("span", { c: "gc-changelog-desc", txt: f.desc }) ]}); list.appendChild(item); }); box.appendChild(list); const dismiss = () => { setGMValue("hasSeenChangelog_4_0", true); overlay.remove(); }; const btnWrap = h("div", { c: "gc-changelog-btn-wrap" }); btnWrap.appendChild(btn("Got it!", dismiss, { classes: ["gc-btn--success", "gc-btn--large"] })); box.appendChild(btnWrap); overlay.appendChild(box); // Close on Escape const escHandler = (e) => { if (e.key === "Escape") { dismiss(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); // Close on backdrop click overlay.addEventListener("click", (e) => { if (e.target === overlay) dismiss(); }); gallery.appendChild(overlay); } // ──────────────────────────────────────────────── // LOAD POSTS // ──────────────────────────────────────────────── function loadPosts() { const addFakeImage = cfg("Add_Placeholder_Image_For_Zoom_Mode"); state.mediaCount = 0; // Show spinner const spinner = h("div", { c: "gc-spinner", ch: [ h("div", { c: "gc-spinner-ring" }), h("div", { c: "gc-spinner-text", txt: "Loading media..." }), ]}); gridContainer.appendChild(spinner); const checkedThreads = isArchivePage ? Array.from(document.querySelectorAll(".flashListing input[type='checkbox']:checked")) .map((cb) => cb.parentNode.parentNode.querySelector("a").href) : [state.threadURL]; if (!checkedThreads.length) { spinner.remove(); gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No threads selected." })); return; } let pendingThreads = checkedThreads.length; function loadFromThread(thread, useFakeImage) { const websiteUrl = thread.replace(/(^\w+:|^)\/\//, "").split("/")[0]; getDocument(thread, state.threadURL).then((doc) => { let posts = [...getPosts(websiteUrl, doc)]; posts.forEach((p) => { p.setAttribute("thread", thread); p.setAttribute("websiteUrl", websiteUrl); }); if (useFakeImage) { const placeholderURL = "https://files.catbox.moe/ecl8vh.png"; const fake = document.createElement("div"); fake.innerHTML = ` <div class="postContainer" id="1231232"> <div class="fileText"> <a href="${placeholderURL}" download="${placeholderURL}">OpenZoomMode[sound=https://files.catbox.moe/brugtt.mp3].jpg</a> </div> <div class="fileThumb"> <img src="${placeholderURL}" alt="Thumbnail"> </div> <div class="postMessage">Just a placeholder image for zoom mode</div> </div>`; fake.setAttribute("thread", "https://boards.4chan.org/b/thread/123456789"); fake.setAttribute("websiteUrl", "boards.4chan.org"); posts = [fake, ...posts]; } // Remove spinner once first thread starts rendering if (spinner.parentNode) spinner.remove(); const frag = document.createDocumentFragment(); posts.forEach((post) => { const wUrl = post.getAttribute("websiteUrl"); const thr = post.getAttribute("thread"); const pd = parsePostData(post, wUrl, thr); let hasEmbeddedLinks = false; let embeddedMatches = []; // Determine media type let isVideo = false, isImage = false, soundLink = null, encodedSoundPostLink = null; if (pd.mediaLink) { const ext = pd.mediaLink.match(FILE_EXT_RE)?.[1]?.toLowerCase(); isVideo = ext ? isVideoExt(ext) : false; isImage = ext ? isImageExt(ext) : false; if (pd.fileName) soundLink = pd.fileName.match(SOUND_RE); } // Check embedded external links if (cfg("Embed_External_Links") && pd.comment) { embeddedMatches = [...pd.comment.innerText.matchAll(LINK_RE)].map((m) => m[0]); if (embeddedMatches.length > 0 && !pd.mediaLink) { pd.mediaLink = embeddedMatches.shift(); pd.fileName = pd.mediaLink.split("/").pop(); pd.thumbnailUrl = pd.mediaLink; const ext = pd.mediaLink.match(FILE_EXT_RE)?.[1]?.toLowerCase(); isVideo = ext ? isVideoExt(ext) : false; isImage = ext ? isImageExt(ext) : false; if (pd.fileName) soundLink = pd.fileName.match(SOUND_RE); } else if (embeddedMatches.length > 0 && pd.mediaLink) { // Has main media + extra embedded links hasEmbeddedLinks = true; } } if (!pd.mediaLink) return; // Mode filter if (state.mode === "webm" && !(isVideo || (isImage && soundLink))) return; if (soundLink) { encodedSoundPostLink = `https://4chan.mahdeensky.top/${pd.board}/thread/${pd.threadID}/${pd.postID}`; } const postURL = pd.postURL + "#" + post.id; const commentText = pd.comment?.innerText || ""; const cell = buildCell( pd.mediaLink, pd.thumbnailUrl, pd.fileName, commentText, soundLink, isVideo, isImage, postURL, pd.board, pd.threadID, pd.postID, false ); if (cell) { frag.appendChild(cell); state.mediaCount++; } // Embedded extra links if (hasEmbeddedLinks) { embeddedMatches.forEach((url) => { const ext = url.match(FILE_EXT_RE)?.[1]?.toLowerCase(); const ev = ext ? isVideoExt(ext) : false; const ei = ext ? isImageExt(ext) : false; const efn = url.split("/").pop(); const esl = efn.match(SOUND_RE); if (state.mode === "webm" && !(ev || (ei && esl))) return; const eCell = buildCell( url, url, efn, commentText, esl, ev, ei, postURL, pd.board, pd.threadID, pd.postID, true ); if (eCell) { frag.appendChild(eCell); state.mediaCount++; } }); } }); gridContainer.appendChild(frag); updateMediaCount(); pendingThreads--; if (pendingThreads <= 0) { if (spinner.parentNode) spinner.remove(); if (state.mediaCount === 0) { gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No media found." })); } else if (searchInputEl && searchInputEl.value) { filterGrid(searchInputEl.value); } } }).catch((err) => { console.error("Failed to load thread:", thread, err); pendingThreads--; if (pendingThreads <= 0) { if (spinner.parentNode) spinner.remove(); if (state.mediaCount === 0) { gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No media found." })); } else if (searchInputEl && searchInputEl.value) { filterGrid(searchInputEl.value); } } }); } loadFromThread(checkedThreads[0], addFakeImage); checkedThreads.slice(1).forEach((t) => loadFromThread(t, false)); } function updateMediaCount() { if (mediaCountEl) { mediaCountEl.textContent = `${state.mediaCount} media`; } } // ──────────────────────────────────────────────── // OPEN GALLERY // ──────────────────────────────────────────────── function openGallery() { // If already exists, just show const existing = document.getElementById("imageGallery"); if (existing) { existing.style.display = "flex"; state.galleryOpen = true; return; } state.galleryOpen = true; state.currentView = "gallery"; document.addEventListener("gc-bookmarks-updated", () => { if (state.currentView === "bookmarks" && !state.zoomActive) { showBookmarksGrid(); } }); gallery = h("div", { a: { id: "imageGallery" }, c: "gc-overlay" }); gridContainer = h("div", { c: "gc-grid", s: { gridTemplateColumns: `repeat(${cfg("Grid_Columns")}, 1fr)` }, }); // Restore size if (state.gallerySize.w > 0 && state.gallerySize.h > 0) { gridContainer.style.width = `${state.gallerySize.w}px`; gridContainer.style.height = `${state.gallerySize.h}px`; } // Grid dragover/drop delegation gridContainer.addEventListener("dragover", (e) => e.preventDefault()); gridContainer.addEventListener("drop", (e) => { e.preventDefault(); const draggedIdx = parseInt(e.dataTransfer.getData("text/plain"), 10); const target = e.target.closest(".gc-cell"); if (!target) return; const children = [...gridContainer.children]; const dropIdx = children.indexOf(target); if (draggedIdx >= 0 && dropIdx >= 0) { const dragged = children[draggedIdx]; if (draggedIdx < dropIdx) { gridContainer.insertBefore(dragged, children[dropIdx].nextSibling); } else { gridContainer.insertBefore(dragged, children[dropIdx]); } } }); // Click delegation → zoom mode gridContainer.addEventListener("click", (e) => { if (e.target.closest("button") || e.target.closest("a")) return; const cell = e.target.closest(".gc-cell"); if (cell) enterZoomMode(cell); }); // ── Top bar: mode toggles ── modeBarEl = h("div", { c: "gc-topbar" }); const toggleModeBtn = btn(`Mode: All`, () => { state.mode = state.mode === "all" ? "webm" : "all"; toggleModeBtn.textContent = state.mode === "all" ? "Mode: All" : "Mode: Webm & Sound"; gridContainer.innerHTML = ""; loadPosts(); }, { classes: ["gc-btn--large"] }); modeBarEl.appendChild(toggleModeBtn); const autoPlayBtn = btn("Auto Play Webms", () => { state.autoPlayWebms = !state.autoPlayWebms; autoPlayBtn.textContent = state.autoPlayWebms ? "Stop Auto Play" : "Auto Play Webms"; autoPlayBtn.classList.toggle("gc-btn--primary", state.autoPlayWebms); gridContainer.innerHTML = ""; loadPosts(); }, { classes: ["gc-btn--large"] }); modeBarEl.appendChild(autoPlayBtn); gallery.appendChild(modeBarEl); // ── Top bar center: search bar ── searchBarEl = h("div", { c: "gc-topbar-center" }); searchInputEl = h("input", { c: "gc-search-input", a: { type: "text", placeholder: "Search comments, filenames, URLs...", } }); searchInputEl.addEventListener("input", (e) => { filterGrid(e.target.value); }); searchInputEl.addEventListener("keydown", (e) => { if (e.key === "Escape") { searchInputEl.value = ""; filterGrid(""); searchInputEl.blur(); e.stopPropagation(); } }); searchBarEl.appendChild(searchInputEl); gallery.appendChild(searchBarEl); // ── Top bar right: settings + bookmarks ── const topRight = h("div", { c: "gc-topbar-right" }); bookmarksBtnEl = btn("Bookmarks", () => { if (searchInputEl) { searchInputEl.value = ""; } if (state.currentView === "gallery") { state.currentView = "bookmarks"; bookmarksBtnEl.textContent = "Show Gallery"; bookmarksBtnEl.classList.add("gc-btn--primary"); modeBarEl.style.display = "none"; showBookmarksGrid(); } else { state.currentView = "gallery"; bookmarksBtnEl.textContent = "Bookmarks"; bookmarksBtnEl.classList.remove("gc-btn--primary"); modeBarEl.style.display = "flex"; gridContainer.innerHTML = ""; loadPosts(); } }, { classes: ["gc-btn--large"] }); topRight.appendChild(bookmarksBtnEl); settingsBtnEl = btn("⚙ Settings", openSettings, { classes: ["gc-btn--large"] }); topRight.appendChild(settingsBtnEl); gallery.appendChild(topRight); // ── Grid ── gallery.appendChild(gridContainer); // ── Close button ── const closeBtn = btn("Close", () => { state.gallerySize = { w: gridContainer.offsetWidth, h: gridContainer.offsetHeight }; state.galleryOpen = false; if (searchInputEl) { searchInputEl.value = ""; } gallery.style.display = "none"; }, { classes: ["gc-btn--large", "gc-float-btn"], attrs: { id: "closeGallery" }, }); setStyles(closeBtn, { position: "fixed", bottom: "12px", right: "12px", zIndex: "10000" }); gallery.appendChild(closeBtn); // ── Scroll to last ── let isScrollToTop = false; const scrollBtn = btn("⬇ Scroll to Last", () => { if (isScrollToTop) { gridContainer.scrollTo({ top: 0, behavior: "smooth" }); } else { const last = gridContainer.lastElementChild; if (last) last.scrollIntoView({ behavior: "smooth" }); } }, { classes: ["gc-btn--large", "gc-float-btn"] }); setStyles(scrollBtn, { position: "fixed", bottom: "12px", left: "12px", zIndex: "10000" }); gallery.appendChild(scrollBtn); const updateScrollBtn = () => { const isAtBottom = gridContainer.scrollHeight - gridContainer.scrollTop - gridContainer.clientHeight < 40; if (isAtBottom && gridContainer.scrollTop > 100) { scrollBtn.textContent = "⬆ Scroll to Top"; isScrollToTop = true; } else { scrollBtn.textContent = "⬇ Scroll to Last"; isScrollToTop = false; } }; gridContainer.addEventListener("scroll", updateScrollBtn); updateScrollBtn(); // ── Media count ── mediaCountEl = h("div", { c: "gc-media-count", txt: "0 media" }); gallery.appendChild(mediaCountEl); document.body.appendChild(gallery); // Load posts loadPosts(); // Scroll persistence const saveScroll = debounce(() => { state.lastScrollPos = gridContainer.scrollTop; }, 100); gridContainer.addEventListener("scroll", saveScroll); // Restore scroll const baseURL = state.threadURL.replace(/#.*$/, ""); if (window.location.href.includes(baseURL)) { requestAnimationFrame(() => { setTimeout(() => { if (state.gallerySize.w > 0 && state.gallerySize.h > 0) { gridContainer.style.width = `${state.gallerySize.w}px`; gridContainer.style.height = `${state.gallerySize.h}px`; } gridContainer.scrollTop = state.lastScrollPos; }, 120); }); } else { state.threadURL = window.location.href; state.lastScrollPos = 0; state.gallerySize = { w: 0, h: 0 }; } // Click outside grid → close gallery.addEventListener("click", (e) => { if (e.target === gallery) closeBtn.click(); }); // Show changelog popup if v4.0 opened for the first time checkChangelog(); } pageBtn.addEventListener("click", openGallery); document.body.appendChild(pageBtn); // ── Archive page checkboxes ── if (isArchivePage) { const thead = document.querySelector(".flashListing thead tr"); if (thead) { const td = h("td", { c: "postblock", txt: "Selected" }); thead.insertBefore(td, thead.firstChild); } document.querySelectorAll(".flashListing tbody tr").forEach((row) => { const cb = h("input", { a: { type: "checkbox" } }); const td = h("td", { ch: cb }); row.insertBefore(td, row.firstChild); }); } } // ═══════════════════════════════════════════════════════════════ // GLOBAL KEYBINDS // ═══════════════════════════════════════════════════════════════ document.addEventListener("keydown", (e) => { // Skip if typing in an input if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable) return; const key = cfg("Open_Close_Gallery_Key"); if (e.key === "Escape" && state.galleryOpen && !state.zoomActive) { // Close gallery with Escape const closeBtn = document.querySelector("#closeGallery"); if (closeBtn) closeBtn.click(); return; } if (e.key === key) { // Don't toggle gallery while zoom is active if (state.zoomActive) return; const g = document.querySelector("#imageGallery"); if (!g) { document.querySelector("#openImageGallery")?.click(); } else if (g.style.display === "none") { document.querySelector("#openImageGallery")?.click(); } else { document.querySelector("#closeGallery")?.click(); } } }); // ═══════════════════════════════════════════════════════════════ // INIT // ═══════════════════════════════════════════════════════════════ injectStyles(); loadButton(); console.log("4chan Gallery v4.1 loaded successfully!"); })();