Sleazy Fork is available in English.
Overhauls Danbooru's default gallery view with a sleek, responsive justified grid layout. Includes high-res thumbnails, hover previews, infinite scroll, and more.
// ==UserScript==
// @name Danbooru Enhanced Gallery
// @namespace https://github.com/yourusername/danbooru-justified-gallery
// @version 1.60
// @description Overhauls Danbooru's default gallery view with a sleek, responsive justified grid layout. Includes high-res thumbnails, hover previews, infinite scroll, and more.
// @author Claude (prompted by orx_ibx)
// @match https://danbooru.donmai.us/
// @match https://danbooru.donmai.us/posts
// @match https://danbooru.donmai.us/posts?*
// @match https://danbooru.donmai.us/?tags=*
// @grant none
// @noframes
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/justifiedGallery/3.8.1/js/jquery.justifiedGallery.min.js
// @license MIT (https://opensource.org/licenses/MIT)
// ==/UserScript==
(function () {
"use strict";
// The infinite scroll loader pulls pages through a hidden iframe; don't run in it
if (window.top !== window.self) return;
// Fail loudly (instead of cryptically) if the @require'd libraries did not load.
if (
!window.jQuery ||
!window.jQuery.fn ||
!window.jQuery.fn.justifiedGallery
) {
console.error(
"[Danbooru Enhanced Gallery] jQuery / Justified Gallery failed to load; script disabled.",
);
return;
}
const $ = window.jQuery.noConflict(true);
// ======================================================================
// Configuration
// ======================================================================
const CONFIG = {
// toggle features
enableHoverPreview: true,
enableHighRes: true,
enableRelocateSearchBar: true,
enableHiddenSidebar: true,
enableDynamicSearch: true, // red search button / Enter key search results in place, no page load
enableInfiniteScroll: true, // load the next page automatically when you scroll near the bottom
// High rez thumbnail loader config
viewportBuffer: 600, // pixels below viewport to start loading HR thumbs
maxRetries: 0, // retry attempts for failed post-info requests (network / 429 / 5xx)
batchSize: 5, // num of HR thumbnails to load at once
batchDelay: 0, // delay between each HR batch load
enableScrollPause: true,
scrollThrottleDelay: 0, // ms to wait after scrolling stops before loading resumes
// Infinite scroll config
infiniteScrollThreshold: 400, // px from the bottom at which the next page starts loading
infiniteScrollThrottle: 200, // ms between scroll checks
// Hover preview config
hoverDelay: 300, // ms delay before showing preview
};
// Settings that can be toggled from the Settings dropdown and are persisted
const SETTINGS_STORAGE_KEY = "DJG_settings";
const PERSISTED_SETTINGS = [
"enableHoverPreview",
"enableRelocateSearchBar",
"enableHiddenSidebar",
"enableDynamicSearch",
"enableInfiniteScroll",
];
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) return;
const saved = JSON.parse(raw);
if (!saved || typeof saved !== "object") return;
PERSISTED_SETTINGS.forEach((key) => {
if (typeof saved[key] === "boolean") CONFIG[key] = saved[key];
});
} catch (e) {
// ignore storage / parse errors and keep defaults
}
}
function saveSettings() {
try {
const data = {};
PERSISTED_SETTINGS.forEach((key) => {
data[key] = CONFIG[key];
});
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(data));
} catch (e) {
// ignore storage errors
}
}
// Restore saved values over the defaults above
loadSettings();
// Config for Justified Gallery (a fresh copy is handed to the plugin each time)
const JG_CONFIG = {
rowHeight: 240,
maxRowHeight: 1080,
lastRow: "nojustify",
margins: 8,
border: 0,
captions: true,
randomize: false,
waitThumbnailsLoad: true,
cssAnimation: false,
imagesAnimationDuration: 0,
captionSettings: {
animationDuration: 0,
visibleOpacity: 0.5,
nonVisibleOpacity: 0.0,
},
};
const jgOptions = () => ({
...JG_CONFIG,
captionSettings: { ...JG_CONFIG.captionSettings },
});
// Elements that trigger a Justified Gallery reset when clicked.
// Handled through one delegated listener, so elements injected later
// (e.g. by other userscripts) work too.
const JG_RESET_SELECTORS = [
"#jg-reload-btn",
"#danbooru-search-enhancement button#local-sort",
"#danbooru-filter-panel #apply-filter",
"#blacklist-box input.toggle-switch",
];
const GALLERY_ID = "justified-gallery-container";
const MAX_URL_CACHE = 2000;
const MAX_TOOLTIP_CACHE = 50;
// Icons (escaped so the file survives any encoding round-trip)
const ICON_UPVOTE = "\u{1F845}";
const ICON_FAVORITE = "\u2764\uFE0E";
const ICON_INFO = "\u{1F6C8}";
// ======================================================================
// Small utilities
// ======================================================================
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const HTML_ESCAPES = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
const escapeHtml = (value) =>
String(value).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
const isPostId = (value) => typeof value === "string" && /^\d+$/.test(value);
function extractPostIdFromUrl(url) {
const match = /\/posts\/(\d+)/.exec(url || "");
return match ? match[1] : null;
}
function isHttpUrl(value) {
try {
const url = new URL(value, window.location.href);
return url.protocol === "http:" || url.protocol === "https:";
} catch (e) {
return false;
}
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") : null;
}
function apiHeaders(csrfToken) {
return {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
"X-Requested-With": "XMLHttpRequest",
};
}
// ======================================================================
// Post image URL resolver (shared by hover preview + high-res loader)
// ======================================================================
// Video / archive URLs can't be shown in an <img>; don't waste bandwidth on them.
const NON_IMAGE_URL = /\.(?:mp4|webm|mkv|zip|swf)(?:[?#]|$)/i;
class HttpError extends Error {
constructor(status) {
super(`HTTP ${status}`);
this.status = status;
}
}
class PostUrlResolver {
constructor() {
this.cache = new Map(); // postId -> url | null (bounded)
this.pending = new Map(); // postId -> Promise (de-duplicates requests)
}
// Always resolves (never rejects) with a URL string or null.
resolve(postId) {
if (this.cache.has(postId)) {
return Promise.resolve(this.cache.get(postId));
}
let request = this.pending.get(postId);
if (!request) {
request = this.fetchWithRetry(postId)
.then((url) => {
this.remember(postId, url);
return url;
})
.catch(() => null) // transient failures are not cached
.finally(() => this.pending.delete(postId));
this.pending.set(postId, request);
}
return request;
}
remember(postId, url) {
if (this.cache.size >= MAX_URL_CACHE) {
this.cache.delete(this.cache.keys().next().value);
}
this.cache.set(postId, url);
}
async fetchWithRetry(postId) {
for (let attempt = 0; ; attempt++) {
try {
return await this.fetchUrl(postId);
} catch (error) {
const retryable =
!(error instanceof HttpError) ||
error.status === 429 ||
error.status >= 500;
if (!retryable || attempt >= CONFIG.maxRetries) throw error;
await sleep(500 * 2 ** attempt);
}
}
}
async fetchUrl(postId) {
const response = await fetch(`/posts/${postId}.json`, {
headers: { Accept: "application/json" },
});
// Restricted / deleted posts: definitive "no image", safe to cache
if (response.status === 403 || response.status === 404) return null;
if (!response.ok) throw new HttpError(response.status);
const data = await response.json();
const url = data && (data.large_file_url || data.file_url);
if (typeof url !== "string" || !isHttpUrl(url)) return null;
if (NON_IMAGE_URL.test(url)) return null;
return url;
}
}
const urlResolver = new PostUrlResolver();
// ======================================================================
// Hover preview (single instance, driven by delegated gallery listeners)
// ======================================================================
class HoverPreview {
constructor() {
this.el = null;
this.current = null; // <img> currently loading / shown
this.active = false; // pointer is over a gallery image
this.token = 0; // invalidates stale async work
this.timer = null;
this.clearTimer = null;
this.raf = 0;
this.x = 0;
this.y = 0;
this.size = { w: 0, h: 0 };
}
ensureElement() {
if (!this.el || !this.el.isConnected) {
this.el = document.createElement("div");
this.el.id = "danbooru-hover-preview";
document.body.appendChild(this.el);
}
return this.el;
}
start(postId, x, y) {
this.stop();
this.active = true;
this.x = x;
this.y = y;
const token = this.token;
this.timer = setTimeout(async () => {
this.timer = null;
const url = await urlResolver.resolve(postId);
if (token !== this.token || !url || !CONFIG.enableHoverPreview) {
return;
}
this.show(url);
}, CONFIG.hoverDelay);
}
// Pointer left the image (or the preview must be cancelled)
stop() {
this.active = false;
this.token++;
clearTimeout(this.timer);
this.timer = null;
this.hide();
}
moveTo(x, y) {
this.x = x;
this.y = y;
if (!this.current || this.raf) return;
this.raf = requestAnimationFrame(() => {
this.raf = 0;
this.reposition();
});
}
show(url) {
const el = this.ensureElement();
clearTimeout(this.clearTimer);
el.style.width = "";
el.style.height = "";
this.size = { w: 0, h: 0 };
const img = document.createElement("img");
this.current = img;
img.onload = () => {
if (this.current !== img) return;
this.fit(img);
this.reposition();
el.classList.add("visible"); // fade in only once there is something to show
};
img.onerror = () => {
if (this.current === img) this.hide();
};
el.replaceChildren(img);
img.src = url;
}
// Display the image at its native device-pixel size (so browser zoom /
// HiDPI don't change its apparent resolution), capped to the viewport.
fit(img) {
const naturalWidth = img.naturalWidth;
const naturalHeight = img.naturalHeight;
if (!naturalWidth || !naturalHeight) return;
const dpr = window.devicePixelRatio || 1;
const fitScale = Math.min(
(window.innerWidth * 0.95 * dpr) / naturalWidth,
(window.innerHeight * 0.95 * dpr) / naturalHeight,
1, // never scale up
);
const scale = fitScale / dpr;
img.style.transform = `scale(${scale})`;
img.style.transformOrigin = "top left";
img.style.width = `${naturalWidth}px`;
img.style.height = `${naturalHeight}px`;
this.size = { w: naturalWidth * scale, h: naturalHeight * scale };
this.el.style.width = `${this.size.w}px`;
this.el.style.height = `${this.size.h}px`;
}
reposition() {
if (!this.el) return;
const dpr = window.devicePixelRatio || 1;
const gap = 15 / dpr;
const margin = 10 / dpr;
const { w, h } = this.size;
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = this.x + gap;
let top = this.y + gap;
if (left + w > vw) left = this.x - w - gap;
if (top + h > vh) top = this.y - h - gap;
left = Math.max(margin, Math.min(left, vw - w - margin));
top = Math.max(margin, Math.min(top, vh - h - margin));
this.el.style.left = `${left}px`;
this.el.style.top = `${top}px`;
}
hide() {
const el = this.el;
if (!el) return;
const img = this.current;
this.current = null;
el.classList.remove("visible");
// Abort a still-loading image
if (img && !img.complete) img.removeAttribute("src");
// Clear content after the fade-out
clearTimeout(this.clearTimer);
this.clearTimer = setTimeout(() => {
if (this.current) return;
el.replaceChildren();
el.style.width = "";
el.style.height = "";
}, 200);
}
}
const hoverPreview = new HoverPreview();
// ======================================================================
// High-resolution thumbnail loader
// ======================================================================
const SCROLL_EVENTS = ["scroll", "wheel", "touchmove"];
class HighResLoader {
constructor() {
this.queue = new Map(); // postId -> img (insertion ordered)
this.jobs = new Map(); // postId -> in-flight job
this.processing = false;
this.destroyed = false;
this.isScrolling = false;
this.scrollTimer = null;
this.nextTimer = null;
this.observer = new IntersectionObserver(
(entries) => this.onIntersect(entries),
{
// preload only below the viewport
rootMargin: `0px 0px ${CONFIG.viewportBuffer}px 0px`,
threshold: 0.1,
},
);
this.onScroll = () => {
this.isScrolling = true;
clearTimeout(this.scrollTimer);
this.scrollTimer = setTimeout(() => {
this.isScrolling = false;
this.process();
}, CONFIG.scrollThrottleDelay);
};
if (CONFIG.enableScrollPause) {
SCROLL_EVENTS.forEach((name) =>
window.addEventListener(name, this.onScroll, {
passive: true,
}),
);
}
}
observe(img) {
if (!this.destroyed) this.observer.observe(img);
}
get paused() {
return CONFIG.enableScrollPause && this.isScrolling;
}
onIntersect(entries) {
for (const entry of entries) {
const img = entry.target;
const postId = img.dataset.postId;
if (!postId) continue;
if (entry.isIntersecting) {
if (img.dataset.highResLoaded !== "true") {
this.queue.delete(postId);
this.queue.set(postId, img);
}
} else {
// Left the viewport: drop it from the queue / stop loading it
this.queue.delete(postId);
this.cancel(postId);
}
}
this.process();
}
async process() {
if (this.destroyed || this.processing || this.queue.size === 0) return;
if (this.paused) return;
this.processing = true;
try {
const batch = [];
for (const [postId, img] of this.queue) {
batch.push([postId, img]);
this.queue.delete(postId);
if (batch.length >= CONFIG.batchSize) break;
}
await Promise.all(batch.map(([id, img]) => this.load(id, img)));
} finally {
this.processing = false;
}
if (!this.destroyed && this.queue.size > 0 && !this.paused) {
this.nextTimer = setTimeout(
() => this.process(),
CONFIG.batchDelay,
);
}
}
cancel(postId) {
const job = this.jobs.get(postId);
if (!job) return;
this.jobs.delete(postId);
job.cancelled = true;
job.img.dataset.highResLoading = "false";
if (job.abort) job.abort();
}
// Never rejects: on any failure the low-res thumbnail simply stays.
async load(postId, img) {
if (img.dataset.highResLoaded === "true" || this.jobs.has(postId)) {
return;
}
const job = { img, cancelled: false, abort: null };
this.jobs.set(postId, job);
img.dataset.highResLoading = "true";
try {
const url = await urlResolver.resolve(postId);
if (!url || job.cancelled || !img.isConnected) return;
if (url !== img.src) {
const ok = await this.preload(url, job);
if (!ok || job.cancelled || !img.isConnected) return;
this.applySource(img, url);
}
img.dataset.highResLoaded = "true";
this.observer.unobserve(img); // nothing left to do for this image
} catch (error) {
// keep the thumbnail
} finally {
if (this.jobs.get(postId) === job) {
this.jobs.delete(postId);
img.dataset.highResLoading = "false";
}
}
}
// Pre-load into a detached Image so the swap is instant; abortable.
preload(url, job) {
return new Promise((resolve) => {
const temp = new Image();
const finish = (ok) => {
temp.onload = temp.onerror = null;
job.abort = null;
resolve(ok);
};
job.abort = () => {
temp.src = "";
finish(false);
};
temp.onload = () => finish(true);
temp.onerror = () => finish(false);
temp.src = url;
});
}
applySource(img, url) {
// Justified Gallery re-applies its own stored source (data "jg.src") every
// time it (re)displays an entry. That happens to the unfinished last row
// (lastRow: "nojustify") on every "norewind" refresh, and to every entry when
// the gallery re-justifies. If JG kept the thumbnail URL, those images would
// silently fall back to low-res, so keep JG's record in sync with the upgrade.
const $img = $(img);
if ($img.data("jg.src")) $img.data("jg.src", url);
// If JG hasn't analysed this entry yet it will read the source from here instead
img.setAttribute("data-safe-src", url);
// Pin the current layout size so the justified rows don't shift
const width = img.offsetWidth;
const height = img.offsetHeight;
img.src = url;
if (width && height) {
img.style.width = `${width}px`;
img.style.height = `${height}px`;
img.style.objectFit = "cover";
}
}
destroy() {
this.destroyed = true;
this.observer.disconnect();
clearTimeout(this.scrollTimer);
clearTimeout(this.nextTimer);
SCROLL_EVENTS.forEach((name) =>
window.removeEventListener(name, this.onScroll),
);
this.queue.clear();
Array.from(this.jobs.keys()).forEach((id) => this.cancel(id));
}
}
// ======================================================================
// Gallery construction / lifecycle
// ======================================================================
// Runtime state
let initialized = false;
let gallery = null; // { container, postsContainer, postIds, loader, postsObserver, syncTimer }
let rebuildTimer = null;
let searchBarState = null;
function getPostId(post) {
const fromAttr = post.getAttribute("data-id");
if (isPostId(fromAttr)) return fromAttr;
const link = post.querySelector(".post-preview-link");
return link ? extractPostIdFromUrl(link.href) : null;
}
// Builds one justified-gallery entry (<a><img><caption/></a>) from a Danbooru post
function createGalleryItem(post, postId) {
const link = post.querySelector(".post-preview-link");
const img = post.querySelector(".post-preview-image");
if (!link || !img) return null;
const parsedScore = Number.parseInt(post.getAttribute("data-score"), 10);
const score = Number.isFinite(parsedScore) ? parsedScore : 0;
const scoreState =
score > 0 ? "positive" : score < 0 ? "negative" : "neutral";
const newLink = document.createElement("a");
newLink.href = link.href;
const newImg = document.createElement("img");
newImg.src = img.src;
newImg.alt = img.alt;
newImg.setAttribute("data-safe-src", img.src);
newImg.dataset.postId = postId;
// Original dimensions, if available
newImg.setAttribute(
"width",
img.getAttribute("width") || img.naturalWidth || 180,
);
newImg.setAttribute(
"height",
img.getAttribute("height") || img.naturalHeight || 180,
);
const caption = document.createElement("div");
caption.className = "jg-caption";
const durationEl = post.querySelector(
"a.post-preview-link div.post-animation-icon .post-duration",
);
const durationHtml = durationEl
? `<span class="content-duration">${escapeHtml(
durationEl.textContent.trim(),
)}</span>`
: "";
// postId is validated (digits only) and score is a number, so this is safe.
caption.innerHTML = `
<span class="caption-button upvote-btn" data-post-id="${postId}">${ICON_UPVOTE}</span>
<span class="score-display ${scoreState}">${score}</span>
<span class="caption-button favorite-btn" data-post-id="${postId}">${ICON_FAVORITE}</span>
<div class="right-side-wrapper">${durationHtml}<span class="info-button" data-post-id="${postId}">${ICON_INFO}</span></div>
`;
newLink.appendChild(newImg);
newLink.appendChild(caption);
return newLink;
}
// Collects gallery entries for posts that aren't in the gallery yet
function collectNewItems(g) {
const items = [];
g.postsContainer
.querySelectorAll("article.post-preview")
.forEach((post) => {
const postId = getPostId(post);
if (!postId || g.postIds.has(postId)) return;
const item = createGalleryItem(post, postId);
if (!item) return;
g.postIds.add(postId);
items.push(item);
});
return items;
}
function observeItems(g, items) {
if (!g.loader) return;
items.forEach((item) => {
const img = item.querySelector("img");
if (img) g.loader.observe(img);
});
}
// Appends posts that were added to Danbooru's container (e.g. infinite scroll)
function syncNewPosts(g) {
if (gallery !== g || !g.container.isConnected) return;
const items = collectNewItems(g);
if (items.length === 0) {
notifyLayoutDone(); // nothing to lay out, don't keep infinite scroll waiting
return;
}
const fragment = document.createDocumentFragment();
items.forEach((item) => fragment.appendChild(item));
g.container.appendChild(fragment);
observeItems(g, items);
try {
$(g.container).justifiedGallery("norewind");
} catch (error) {
console.warn("[DJG] Gallery refresh failed:", error);
notifyLayoutDone();
}
}
function watchPostsContainer(g) {
const scheduleSync = () => {
clearTimeout(g.syncTimer);
g.syncTimer = setTimeout(() => syncNewPosts(g), 50);
};
g.postsObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (
node.matches("article.post-preview") ||
node.querySelector("article.post-preview")
) {
scheduleSync();
return;
}
}
}
});
g.postsObserver.observe(g.postsContainer, {
childList: true,
subtree: true,
});
}
function attachHoverHandlers(container) {
container.addEventListener("mouseover", (e) => {
if (!CONFIG.enableHoverPreview) return;
const img = e.target;
if (!(img instanceof HTMLImageElement)) return;
const postId = img.dataset.postId;
if (!postId) return;
hoverPreview.start(postId, e.clientX, e.clientY);
});
container.addEventListener("mouseout", (e) => {
if (e.target instanceof HTMLImageElement) hoverPreview.stop();
});
container.addEventListener(
"mousemove",
(e) => {
if (hoverPreview.active) hoverPreview.moveTo(e.clientX, e.clientY);
},
{ passive: true },
);
}
function buildGallery() {
const postsContainer = document.querySelector(".posts-container");
if (!postsContainer || !postsContainer.parentNode) return;
const container = document.createElement("div");
container.className = "justified-gallery";
container.id = GALLERY_ID;
const g = {
container,
postsContainer,
postIds: new Set(),
loader: null,
postsObserver: null,
syncTimer: null,
};
const items = collectNewItems(g);
const fragment = document.createDocumentFragment();
items.forEach((item) => fragment.appendChild(item));
container.appendChild(fragment);
postsContainer.parentNode.insertBefore(container, postsContainer);
// Infinite scroll waits for the layout; bind before init in case it finishes instantly
$(container).on("jg.complete", notifyLayoutDone);
holdForLayout();
try {
$(container).justifiedGallery(jgOptions());
} catch (error) {
// Fall back to Danbooru's default gallery rather than showing nothing
console.error("[DJG] Justified Gallery init failed:", error);
$(container).off("jg.complete");
container.remove();
document.documentElement.classList.add("DJG-failed");
notifyLayoutDone();
return;
}
document.documentElement.classList.remove("DJG-failed");
gallery = g;
if (CONFIG.enableHighRes) {
g.loader = new HighResLoader();
observeItems(g, items);
}
container.addEventListener("click", handleGalleryClick);
attachHoverHandlers(container);
watchPostsContainer(g);
}
function destroyGallery() {
hoverPreview.stop();
hideTooltip();
const g = gallery;
gallery = null;
if (g) {
clearTimeout(g.syncTimer);
if (g.postsObserver) g.postsObserver.disconnect();
if (g.loader) g.loader.destroy();
$(g.container).off("jg.complete");
try {
$(g.container).justifiedGallery("destroy");
} catch (e) {
// plugin was not initialised; nothing to destroy
}
g.container.remove();
}
// Safety net: never leave a stray duplicate behind
const stray = document.getElementById(GALLERY_ID);
if (stray) stray.remove();
}
function rebuildGallery() {
destroyGallery();
buildGallery();
}
// Coalesces any number of rebuild triggers (resize, settings, buttons...) into one
function scheduleRebuild(delay = 0) {
clearTimeout(rebuildTimer);
rebuildTimer = setTimeout(() => {
rebuildTimer = null;
rebuildGallery();
}, delay);
}
// ======================================================================
// Caption actions (vote / favorite) - one delegated listener per gallery
// ======================================================================
function setScore(scoreEl, value) {
scoreEl.textContent = String(value);
scoreEl.classList.toggle("positive", value > 0);
scoreEl.classList.toggle("negative", value < 0);
scoreEl.classList.toggle("neutral", value === 0);
}
async function toggleVote(button) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
console.warn("[DJG] CSRF token not found");
return;
}
const postId = button.dataset.postId;
if (!isPostId(postId)) return;
const caption = button.closest(".jg-caption");
const scoreEl = caption && caption.querySelector(".score-display");
const wasUpvoted = button.classList.contains("upvoted");
button.classList.add("processing");
try {
const response = await fetch(`/posts/${postId}/votes`, {
method: wasUpvoted ? "DELETE" : "POST",
headers: apiHeaders(csrfToken),
body: JSON.stringify({ score: 1 }),
});
if (!response.ok) {
console.warn(`[DJG] Vote failed (HTTP ${response.status})`);
return;
}
// Adjust by one locally; don't depend on the server response shape
if (scoreEl) {
const current = Number.parseInt(scoreEl.textContent, 10) || 0;
setScore(scoreEl, current + (wasUpvoted ? -1 : 1));
}
button.classList.toggle("upvoted", !wasUpvoted);
button.classList.toggle("active", !wasUpvoted);
} catch (error) {
console.error("[DJG] Error voting:", error);
} finally {
button.classList.remove("processing");
}
}
async function toggleFavorite(button) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
console.warn("[DJG] CSRF token not found");
return;
}
const postId = button.dataset.postId;
if (!isPostId(postId)) return;
const wasFavorited = button.classList.contains("favorited");
button.classList.add("processing");
try {
const response = await fetch(
wasFavorited ? `/favorites/${postId}.json` : "/favorites.json",
{
method: wasFavorited ? "DELETE" : "POST",
headers: apiHeaders(csrfToken),
body: JSON.stringify({ post_id: postId }),
},
);
if (!response.ok) {
console.warn(`[DJG] Favorite failed (HTTP ${response.status})`);
return;
}
button.classList.toggle("active", !wasFavorited);
button.classList.toggle("favorited", !wasFavorited);
} catch (error) {
console.error("[DJG] Error favoriting:", error);
} finally {
button.classList.remove("processing");
}
}
function handleGalleryClick(e) {
if (!(e.target instanceof Element)) return;
// Clicks anywhere on the caption bar must not open the post
if (!e.target.closest(".jg-caption")) return;
e.preventDefault();
const button = e.target.closest(".upvote-btn, .favorite-btn");
if (!button) return;
e.stopPropagation();
if (button.classList.contains("processing")) return;
if (button.classList.contains("upvote-btn")) toggleVote(button);
else toggleFavorite(button);
}
// ======================================================================
// Post info tooltip
// ======================================================================
const TAG_LISTS = [
["artist", "Artist"],
["copyright", "Copyright"],
["character", "Character"],
["general", "General"],
["meta", "Meta"],
];
const BLOCKED_TAGS =
"script,style,iframe,object,embed,template,noscript,link,meta,base,form,frame,frameset,applet";
const URL_ATTRIBUTES = ["href", "src", "action", "formaction", "poster"];
const tooltipState = {
abort: null,
cache: new Map(), // postId -> DocumentFragment (bounded)
};
function createTooltip() {
const tooltip = document.createElement("div");
tooltip.className = "post-info-tooltip";
tooltip.id = "post-info-tooltip";
document.body.appendChild(tooltip);
return tooltip;
}
function hideTooltip() {
if (tooltipState.abort) {
tooltipState.abort.abort();
tooltipState.abort = null;
}
const tooltip = document.getElementById("post-info-tooltip");
if (tooltip) tooltip.classList.remove("visible");
}
function positionTooltip(tooltip, button) {
const buttonRect = button.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Convert viewport coordinates to document coordinates
const scrollX = window.pageXOffset || document.documentElement.scrollLeft;
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
// Prefer the right side of the button, otherwise the left
let left =
buttonRect.right + tooltipRect.width + 20 < viewportWidth
? buttonRect.right + 10 + scrollX
: buttonRect.left - tooltipRect.width - 10 + scrollX;
// Vertically centred on the button
let top =
buttonRect.top +
buttonRect.height / 2 -
tooltipRect.height / 2 +
scrollY;
// Keep within the viewport
const minTop = 10 + scrollY;
const maxTop = scrollY + viewportHeight - tooltipRect.height - 10;
const minLeft = 10 + scrollX;
const maxLeft = scrollX + viewportWidth - tooltipRect.width - 10;
if (top < minTop) top = minTop;
if (top > maxTop) top = maxTop;
if (left < minLeft) left = minLeft;
if (left > maxLeft) left = maxLeft;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
}
// Removes anything that could execute or navigate somewhere unexpected.
// (The markup comes from Danbooru itself, but we still don't trust it blindly.)
function sanitizeTree(root) {
root.querySelectorAll(BLOCKED_TAGS).forEach((el) => el.remove());
root.querySelectorAll("*").forEach((el) => {
Array.from(el.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
if (name.startsWith("on") || name === "srcdoc") {
el.removeAttribute(attr.name);
} else if (
URL_ATTRIBUTES.includes(name) &&
!isHttpUrl(attr.value)
) {
el.removeAttribute(attr.name);
}
});
if (el.tagName === "A" && el.hasAttribute("target")) {
el.setAttribute("rel", "noopener noreferrer");
}
});
}
function makeElement(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function appendListSection(parent, doc, selector, title, className) {
const sourceList = doc.querySelector(selector);
const items = sourceList ? sourceList.querySelectorAll("li") : [];
if (items.length === 0) return;
const section = makeElement("div", `tooltip-section ${className}`);
section.appendChild(makeElement("h3", "", title));
const list = document.createElement("ul");
items.forEach((item) => {
const imported = document.importNode(item, true);
sanitizeTree(imported);
const li = document.createElement("li");
while (imported.firstChild) li.appendChild(imported.firstChild);
list.appendChild(li);
});
section.appendChild(list);
parent.appendChild(section);
}
// Turns a fetched post page into tooltip content (tags + information + options)
function buildTooltipContent(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const fragment = document.createDocumentFragment();
TAG_LISTS.forEach(([type, label]) => {
const list = doc.querySelector(`ul.${type}-tag-list`);
if (!list) return;
const wrapper = makeElement("div", "tooltip-tags");
list.querySelectorAll("li").forEach((item) => {
const link = item.querySelector(".search-tag");
if (!link) return;
const typeMatch = /tag-type-(\d+)/.exec(item.className);
const anchor = makeElement(
"a",
`tooltip-tag tag-type-${typeMatch ? typeMatch[1] : "0"}`,
link.textContent.trim(),
);
const href = link.getAttribute("href");
if (href && isHttpUrl(href)) anchor.setAttribute("href", href);
wrapper.appendChild(anchor);
});
if (!wrapper.firstChild) return;
const section = makeElement("div", "tooltip-section");
section.appendChild(makeElement("h3", "", label));
section.appendChild(wrapper);
fragment.appendChild(section);
});
appendListSection(
fragment,
doc,
"#post-information ul",
"Information",
"tooltip-info",
);
appendListSection(
fragment,
doc,
"#post-options ul",
"Options",
"tooltip-options",
);
return fragment;
}
function renderTooltip(tooltip, content) {
const exitContainer = makeElement("div", "tooltip-exit-button-container");
const exitButton = makeElement("button", "tooltip-exit-button", "\u00D7");
exitButton.id = "tooltip-exit-button";
exitButton.type = "button";
exitButton.setAttribute("aria-label", "Close");
exitContainer.appendChild(exitButton);
tooltip.replaceChildren(exitContainer, content);
}
function renderTooltipMessage(tooltip, message) {
tooltip.replaceChildren(makeElement("div", "tooltip-loading", message));
}
function rememberTooltip(postId, fragment) {
const cache = tooltipState.cache;
if (cache.size >= MAX_TOOLTIP_CACHE) {
cache.delete(cache.keys().next().value);
}
cache.set(postId, fragment);
}
async function showPostInfo(postId, button) {
if (!isPostId(postId)) return;
const tooltip =
document.getElementById("post-info-tooltip") || createTooltip();
// Clicking the same button again toggles the tooltip off
if (
tooltip.classList.contains("visible") &&
tooltip.dataset.currentPostId === postId
) {
hideTooltip();
return;
}
// Cancel any request for a previously clicked post
if (tooltipState.abort) tooltipState.abort.abort();
tooltipState.abort = null;
tooltip.dataset.currentPostId = postId;
tooltip.classList.add("visible");
const cached = tooltipState.cache.get(postId);
if (cached) {
renderTooltip(tooltip, cached.cloneNode(true));
positionTooltip(tooltip, button);
return;
}
renderTooltipMessage(tooltip, "Loading post information...");
positionTooltip(tooltip, button);
const controller = new AbortController();
tooltipState.abort = controller;
try {
const response = await fetch(`/posts/${postId}`, {
signal: controller.signal,
headers: { Accept: "text/html" },
});
if (!response.ok) throw new HttpError(response.status);
const html = await response.text();
// Ignore stale results (user moved on to another post / closed the tooltip)
if (
controller.signal.aborted ||
tooltip.dataset.currentPostId !== postId ||
!tooltip.classList.contains("visible")
) {
return;
}
const content = buildTooltipContent(html);
rememberTooltip(postId, content.cloneNode(true));
renderTooltip(tooltip, content);
positionTooltip(tooltip, button);
} catch (error) {
if (error.name === "AbortError") return;
console.error("[DJG] Error fetching post info:", error);
if (tooltip.dataset.currentPostId === postId) {
renderTooltipMessage(tooltip, "Error loading post information");
}
} finally {
if (tooltipState.abort === controller) tooltipState.abort = null;
}
}
// One click + one keydown listener for the whole page (registered once)
function setupTooltipHandlers() {
document.addEventListener("click", (e) => {
if (!(e.target instanceof Element)) return;
const infoButton = e.target.closest(".info-button");
if (infoButton) {
e.preventDefault();
e.stopPropagation();
showPostInfo(infoButton.dataset.postId, infoButton);
return;
}
if (e.target.closest(".tooltip-exit-button")) {
hideTooltip();
return;
}
// Click outside the tooltip closes it
if (!e.target.closest(".post-info-tooltip")) hideTooltip();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" || e.key === "Esc") hideTooltip();
});
}
// ======================================================================
// Search bar relocation
// ======================================================================
function relocateSearchBar() {
// Already relocated
if (searchBarState) return true;
const searchForm = document.getElementById("search-box-form");
const contentSection = document.getElementById("content");
if (!searchForm || !contentSection) return false;
// Remember the original state so the change can be reverted
const state = {
form: searchForm,
parent: searchForm.parentNode,
next: searchForm.nextSibling,
styles: [], // [element, original style attribute]
container: null,
containerDisplay: "",
input: null,
placeholder: null,
wrapper: null,
};
const setStyle = (el, css) => {
state.styles.push([el, el.getAttribute("style")]);
el.style.cssText = css;
};
// Hide the original search form container to prevent empty space
const originalContainer = searchForm.closest(
'.search-box, #search-box, [class*="search"]',
);
if (originalContainer && originalContainer !== searchForm) {
state.container = originalContainer;
state.containerDisplay = originalContainer.style.display;
originalContainer.style.display = "none";
}
// New container for the relocated search bar
const searchContainer = document.createElement("div");
searchContainer.id = "relocated-search-container";
searchContainer.style.cssText = `
display: flex;
justify-content: stretch;
margin-bottom: 5px;
padding: 0;
`;
state.wrapper = searchContainer;
setStyle(
searchForm,
`
display: flex;
align-items: center;
width: 100%;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
`,
);
const searchInput = searchForm.querySelector("#tags");
if (searchInput) {
state.input = searchInput;
state.placeholder = searchInput.getAttribute("placeholder");
setStyle(
searchInput,
`
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
color: #fff;
font-size: 14px;
outline: none;
min-width: 0;
`,
);
searchInput.setAttribute(
"placeholder",
state.placeholder || "Search tags...",
);
}
// (hover colour for the button is handled in CSS)
const searchButton = searchForm.querySelector("#search-box-submit");
if (searchButton) {
setStyle(
searchButton,
`
padding: 8px 12px;
border: none;
background: #0066cc;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
min-width: 40px;
`,
);
}
// (skip the icon cloned into the dynamic-search button)
const searchIcon = Array.from(
searchForm.querySelectorAll(".search-icon"),
).find((el) => !el.closest("#DJG-dynamic-search-btn"));
if (searchIcon) {
setStyle(
searchIcon,
`
width: 16px;
height: 16px;
`,
);
}
// Move the form into the new container, at the top of the content section
searchContainer.appendChild(searchForm);
contentSection.insertBefore(searchContainer, contentSection.firstChild);
searchBarState = state;
return true;
}
function restoreSearchBar() {
const state = searchBarState;
if (!state) return;
// Move the form back to where it was
if (state.parent && state.parent.isConnected) {
const ref =
state.next && state.next.parentNode === state.parent
? state.next
: null;
state.parent.insertBefore(state.form, ref);
}
// Restore inline styles / attributes
state.styles.forEach(([el, style]) => {
if (style === null) el.removeAttribute("style");
else el.setAttribute("style", style);
});
if (state.input) {
if (state.placeholder === null) {
state.input.removeAttribute("placeholder");
} else {
state.input.setAttribute("placeholder", state.placeholder);
}
}
// Show the original container again
if (state.container) {
state.container.style.display = state.containerDisplay;
}
if (state.wrapper) state.wrapper.remove();
searchBarState = null;
}
// ======================================================================
// Dynamic search (runs searches in place, without a page load)
// ======================================================================
const dynamicSearch = {
button: null,
busy: false,
navigated: false, // true once this page has pushed a history entry
toastTimer: null,
};
function showToast(message) {
let toast = document.getElementById("DJG-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "DJG-toast";
document.body.appendChild(toast);
}
toast.textContent = message;
clearTimeout(dynamicSearch.toastTimer);
dynamicSearch.toastTimer = setTimeout(() => toast.remove(), 2000);
}
function setDynamicSearchBusy(busy) {
dynamicSearch.busy = busy;
if (dynamicSearch.button) dynamicSearch.button.disabled = busy;
}
function importSanitized(node) {
const imported = document.importNode(node, true);
sanitizeTree(imported);
return imported;
}
async function runDynamicSearch() {
const input = document.querySelector("#tags");
if (!input) return;
const query = input.value.trim();
if (!query) {
showToast("Enter a search query!");
return;
}
if (dynamicSearch.busy) return;
const url = new URL("/posts", window.location.origin);
url.searchParams.set("tags", query);
url.searchParams.set("page", "1");
// No gallery on this page (e.g. an empty result page): nothing to update in place
const container = document.querySelector(".posts-container");
if (!container) {
window.location.assign(url.href);
return;
}
setDynamicSearchBusy(true);
try {
const response = await fetch(url, {
headers: { Accept: "text/html" },
});
if (!response.ok) throw new HttpError(response.status);
const doc = new DOMParser().parseFromString(
await response.text(),
"text/html",
);
const newContainer = doc.querySelector(".posts-container");
if (!newContainer || newContainer.children.length === 0) {
showToast("No posts found!");
return;
}
if (!container.isConnected) return; // page changed while we were fetching
// Keep pagination in sync. If the new results need a paginator that this
// page doesn't have, there is no reliable place to put it: do a normal load.
const oldPaginators = Array.from(
document.querySelectorAll(".paginator"),
);
const newPaginators = Array.from(doc.querySelectorAll(".paginator"));
if (newPaginators.length > oldPaginators.length) {
window.location.assign(url.href);
return;
}
container.replaceChildren(...importSanitized(newContainer).children);
oldPaginators.forEach((oldPaginator, i) => {
const next = newPaginators[i];
if (next) oldPaginator.replaceWith(importSanitized(next));
else oldPaginator.remove(); // new results fit on one page
});
// Infinite scroll must continue from the new results, not the old query
resetInfiniteScroll();
if (url.href !== window.location.href) {
history.pushState({}, "", url);
dynamicSearch.navigated = true;
}
document.title = doc.title;
// Rebuild the justified gallery from the new posts right away
// (replaces the delayed rebuild the history hook just scheduled)
scheduleRebuild(0);
showToast(`Loaded: "${query}"`);
} catch (error) {
console.error("[DJG] Dynamic search failed:", error);
showToast(`Error: ${error.message}`);
} finally {
setDynamicSearchBusy(false);
}
}
function createDynamicSearchButton() {
if (dynamicSearch.button && dynamicSearch.button.isConnected) return;
const submit = document.querySelector("#search-box-submit");
if (!submit || !submit.parentNode) return;
const button = document.createElement("button");
button.id = "DJG-dynamic-search-btn";
button.type = "button";
button.title = "Search without reloading the page";
button.setAttribute("aria-label", "Search without reloading the page");
// Reuse the original button's icon so it looks identical
const icon = submit.querySelector("svg");
if (icon) {
const clone = icon.cloneNode(true);
clone.removeAttribute("style");
button.appendChild(clone);
} else {
button.textContent = "\u{1F50D}";
}
// Same styles as the original button, but red
Object.assign(button.style, {
padding: "8px 12px",
border: "none",
borderRadius: "0",
background: "rgb(253, 7, 75)",
color: "white",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background-color 0.2s",
minWidth: "40px",
});
button.addEventListener("click", (e) => {
e.preventDefault();
runDynamicSearch();
});
submit.parentNode.insertBefore(button, submit);
dynamicSearch.button = button;
}
function removeDynamicSearchButton() {
if (dynamicSearch.button) dynamicSearch.button.remove();
dynamicSearch.button = null;
}
function applyDynamicSearchSetting() {
if (CONFIG.enableDynamicSearch) createDynamicSearchButton();
else removeDynamicSearchButton();
}
// Registered once; every handler checks CONFIG.enableDynamicSearch when it fires,
// so toggling the setting needs no listener bookkeeping.
function setupDynamicSearch() {
const input = document.querySelector("#tags");
if (input) {
// Enter runs the dynamic search (unless an autocomplete suggestion is highlighted)
input.addEventListener(
"keydown",
(e) => {
if (!CONFIG.enableDynamicSearch) return;
if (e.key !== "Enter" || e.isComposing) return;
if (document.querySelector(".ui-autocomplete .ui-state-active")) {
return;
}
// Without a gallery to update, let the normal form submit run
if (!document.querySelector(".posts-container")) return;
e.preventDefault();
e.stopImmediatePropagation();
runDynamicSearch();
},
true,
);
}
// The page content no longer matches the URL after back/forward: reload it
window.addEventListener("popstate", () => {
if (dynamicSearch.navigated) window.location.reload();
});
applyDynamicSearchSetting();
}
// ======================================================================
// Infinite scroll (loads the next page(s) as you approach the bottom)
// ======================================================================
//
// Pages are loaded through a hidden iframe and their posts are read from the
// iframe's document (the approach that is known to work on Danbooru; a plain
// fetch() + DOMParser did not). New posts are appended to Danbooru's own
// (hidden) .posts-container, and the posts observer then adds them to the
// justified gallery. The next page is only requested once the gallery has laid
// out the previous one, otherwise the page would still look "short" and every
// page would be requested in a rush.
const INFINITE_FRAME_TIMEOUT = 20000; // give up on a page that never finishes loading
const INFINITE_LAYOUT_FALLBACK = 5000; // longest wait for the gallery to lay out new posts
const INFINITE_MAX_RETRIES = 2;
const INFINITE_COOLDOWN = 30000; // pause after repeated failures
const infinite = {
initialized: false,
nextUrl: null, // next page to load (null = no more pages)
loading: false,
frame: null,
generation: 0, // bumped to discard any in-flight load
layoutPending: false, // new posts were added; wait for the gallery layout
layoutTimer: null,
scrollTimer: null,
watchdog: null,
retries: 0,
blockedUntil: 0,
};
// Next-page link of a document (same-origin http(s) only), or null
function getNextPageUrl(doc) {
const link =
doc.querySelector(
"a[rel='next'], a.paginator-next, .paginator a[alt='next']",
) ||
Array.from(doc.querySelectorAll(".paginator a")).find((a) =>
/^(>|\u203A|\u00BB|next)/i.test(a.textContent.trim()),
);
if (!link || !link.href) return null;
try {
const url = new URL(link.href, window.location.href);
return url.origin === window.location.origin ? url.href : null;
} catch (e) {
return null;
}
}
function ensureInfiniteFrame() {
if (infinite.frame && infinite.frame.isConnected) return infinite.frame;
const frame = document.createElement("iframe");
frame.id = "DJG-infinite-frame";
frame.title = "Infinite scroll loader";
frame.tabIndex = -1;
frame.setAttribute("aria-hidden", "true");
frame.style.cssText =
"width:0;height:0;border:0;visibility:hidden;position:absolute";
frame.addEventListener("load", onInfiniteFrameLoad);
document.body.appendChild(frame);
infinite.frame = frame;
return frame;
}
// Navigate the loader to a blank page: cancels a pending load and frees the old page
function releaseInfiniteFrame() {
const frame = infinite.frame;
if (!frame || !frame.isConnected) return;
try {
frame.contentWindow.location.replace("about:blank");
} catch (e) {
// frame not reachable; nothing to release
}
}
function infiniteCheck() {
if (!CONFIG.enableInfiniteScroll) return;
if (infinite.loading || infinite.layoutPending) return;
if (!infinite.nextUrl || Date.now() < infinite.blockedUntil) return;
const remaining =
document.documentElement.scrollHeight -
(window.innerHeight + window.scrollY);
if (remaining < CONFIG.infiniteScrollThreshold) loadNextPage();
}
function loadNextPage() {
if (infinite.loading || !infinite.nextUrl) return;
let frame;
try {
frame = ensureInfiniteFrame();
} catch (e) {
return;
}
infinite.loading = true;
const token = infinite.generation;
infinite.watchdog = setTimeout(() => {
if (token === infinite.generation && infinite.loading) {
failInfiniteLoad();
}
}, INFINITE_FRAME_TIMEOUT);
try {
frame.contentWindow.location.replace(infinite.nextUrl);
} catch (error) {
console.warn("[DJG] Infinite scroll could not start a load:", error);
failInfiniteLoad();
}
}
function onInfiniteFrameLoad() {
const frame = infinite.frame;
if (!infinite.loading || !frame) return;
let doc;
try {
doc = frame.contentDocument;
} catch (e) {
return;
}
// Ignore the frame's initial / released "about:blank" documents
if (!doc || !doc.location || doc.location.href === "about:blank") return;
// Give the page a moment to finish rendering before reading it
const token = infinite.generation;
setTimeout(() => {
if (token !== infinite.generation || !infinite.loading) return;
consumeInfinitePage(doc);
}, 100);
}
function consumeInfinitePage(doc) {
clearTimeout(infinite.watchdog);
const container = document.querySelector(".posts-container");
const pageContainer = doc.querySelector(".posts-container");
if (!container || !pageContainer) {
// Not a result page (rate limit, outage...): retry instead of stopping for good
failInfiniteLoad();
return;
}
const pagePosts = Array.from(
doc.querySelectorAll(".posts-container .post-preview"),
);
const existing = new Set(
Array.from(container.querySelectorAll(".post-preview"), (p) => p.dataset.id),
);
const fresh = pagePosts.filter((p) => !existing.has(p.dataset.id));
if (fresh.length > 0) {
const fragment = document.createDocumentFragment();
fresh.forEach((post) => fragment.appendChild(importSanitized(post)));
container.appendChild(fragment);
// The posts observer adds them to the gallery; wait for that layout
if (gallery) holdForLayout();
}
console.debug(
`[DJG] Infinite scroll: +${fresh.length} posts from ${doc.location.href}`,
);
// A page whose posts were all duplicates (posts shifted between pages) can still
// have a next page; only an empty page or a missing next link ends the list.
infinite.nextUrl = pagePosts.length > 0 ? getNextPageUrl(doc) : null;
infinite.retries = 0;
infinite.loading = false;
if (!infinite.nextUrl) releaseInfiniteFrame();
if (fresh.length === 0) infiniteCheck(); // otherwise the layout callback checks
}
function failInfiniteLoad() {
clearTimeout(infinite.watchdog);
infinite.loading = false;
releaseInfiniteFrame();
if (infinite.retries < INFINITE_MAX_RETRIES) {
infinite.retries++;
const token = infinite.generation;
setTimeout(() => {
if (token === infinite.generation) infiniteCheck();
}, 1500 * infinite.retries);
return;
}
infinite.retries = 0;
infinite.blockedUntil = Date.now() + INFINITE_COOLDOWN;
showToast("Couldn't load more posts");
}
// The gallery is about to lay out new entries: hold further page loads until it is done
function holdForLayout() {
if (!CONFIG.enableInfiniteScroll) return;
infinite.layoutPending = true;
clearTimeout(infinite.layoutTimer);
infinite.layoutTimer = setTimeout(notifyLayoutDone, INFINITE_LAYOUT_FALLBACK);
}
// Called on Justified Gallery's "jg.complete" (or by the fallback timer)
function notifyLayoutDone() {
if (!infinite.layoutPending) return;
infinite.layoutPending = false;
clearTimeout(infinite.layoutTimer);
setTimeout(infiniteCheck, 50); // let the new layout settle before measuring
}
// Stop whatever is loading; results of in-flight loads are discarded
function cancelInfiniteLoad() {
infinite.generation++;
infinite.loading = false;
clearTimeout(infinite.watchdog);
releaseInfiniteFrame();
}
// Start over from the page's current paginator (first run, or after a dynamic search)
function resetInfiniteScroll() {
cancelInfiniteLoad();
infinite.initialized = true;
infinite.retries = 0;
infinite.blockedUntil = 0;
infinite.nextUrl = getNextPageUrl(document);
}
function applyInfiniteScrollSetting() {
if (CONFIG.enableInfiniteScroll) {
// Keep our place when re-enabled: pages already loaded stay in the gallery
if (!infinite.initialized) resetInfiniteScroll();
infiniteCheck();
} else {
cancelInfiniteLoad();
}
}
// The scroll listener is registered once and checks CONFIG when it fires
function setupInfiniteScroll() {
window.addEventListener(
"scroll",
() => {
if (!CONFIG.enableInfiniteScroll || infinite.scrollTimer) return;
infinite.scrollTimer = setTimeout(() => {
infinite.scrollTimer = null;
infiniteCheck();
}, CONFIG.infiniteScrollThrottle);
},
{ passive: true },
);
applyInfiniteScrollSetting();
}
// ======================================================================
// Settings dropdown
// ======================================================================
function applyHoverPreviewSetting() {
if (!CONFIG.enableHoverPreview) hoverPreview.stop();
// Listeners are delegated and check CONFIG on every event, so nothing else to do
}
function applySearchBarSetting() {
if (CONFIG.enableRelocateSearchBar) relocateSearchBar();
else restoreSearchBar();
}
function applySidebarSetting() {
let style = document.getElementById("DJG-sidebar-style");
if (!style) {
style = document.createElement("style");
style.id = "DJG-sidebar-style";
document.head.appendChild(style);
}
style.textContent = CONFIG.enableHiddenSidebar
? "#sidebar {display: none;}"
: "";
}
const SETTINGS_DEFS = [
{
key: "enableHoverPreview",
label: "Hover preview",
apply: applyHoverPreviewSetting,
},
{
key: "enableRelocateSearchBar",
label: "Wide search bar",
apply: applySearchBarSetting,
},
{
key: "enableDynamicSearch",
label: "Dynamic search",
apply: applyDynamicSearchSetting,
},
{
key: "enableInfiniteScroll",
label: "Infinite scroll",
apply: applyInfiniteScrollSetting,
},
{
key: "enableHiddenSidebar",
label: "Hide sidebar",
apply: () => {
applySidebarSetting();
// Sidebar visibility changes the available width, so re-layout the gallery
scheduleRebuild(0);
},
},
];
function syncSettingsUI() {
const menu = document.getElementById("DJG-settings-menu");
if (!menu) return;
menu.querySelectorAll("input[data-setting-key]").forEach((input) => {
input.checked = !!CONFIG[input.dataset.settingKey];
});
}
function createSettingsMenu() {
// Already built (listeners are only attached once, together with the elements)
if (document.getElementById("DJG-settings")) return;
const wrapper = document.createElement("span");
wrapper.id = "DJG-settings";
const button = document.createElement("button");
button.id = "DJG-settings-btn";
button.type = "button";
button.textContent = "\u2699 Settings";
button.setAttribute("aria-haspopup", "true");
button.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.id = "DJG-settings-menu";
menu.setAttribute("role", "menu");
SETTINGS_DEFS.forEach((def) => {
const label = document.createElement("label");
label.className = "DJG-settings-item";
const input = document.createElement("input");
input.type = "checkbox";
input.dataset.settingKey = def.key;
input.checked = !!CONFIG[def.key];
const text = document.createElement("span");
text.textContent = def.label;
input.addEventListener("change", () => {
CONFIG[def.key] = input.checked;
saveSettings();
def.apply();
});
label.appendChild(input);
label.appendChild(text);
menu.appendChild(label);
});
wrapper.appendChild(button);
wrapper.appendChild(menu);
// Place next to the reload button
const postsSection = document.querySelector("#post-sections li");
if (postsSection) {
postsSection.appendChild(wrapper);
} else {
// Fallback to body if the posts section is not found
wrapper.classList.add("floating");
document.body.appendChild(wrapper);
}
const setOpen = (open) => {
menu.classList.toggle("visible", open);
button.setAttribute("aria-expanded", String(open));
if (open) syncSettingsUI();
};
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!menu.classList.contains("visible"));
});
// Close when clicking outside the menu
document.addEventListener("click", (e) => {
if (!wrapper.contains(e.target)) setOpen(false);
});
// Close on Escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" || e.key === "Esc") setOpen(false);
});
}
function createReloadButton() {
// Don't create a second reload button
if (document.getElementById("jg-reload-btn")) return;
const postsSection = document.querySelector("#post-sections li");
if (!postsSection) return;
const reloadButton = document.createElement("button");
reloadButton.id = "jg-reload-btn";
reloadButton.type = "button";
reloadButton.textContent = "\u21BB";
reloadButton.title = "Reload gallery layout";
reloadButton.setAttribute("aria-label", "Reload gallery layout");
postsSection.appendChild(reloadButton);
// The click is handled by the delegated reset handler (JG_RESET_SELECTORS)
}
// ======================================================================
// Global triggers: reset buttons, navigation, resize
// ======================================================================
function setupResetHandlers() {
const selector = JG_RESET_SELECTORS.join(",");
if (!selector) return;
// Capture phase so page handlers calling stopPropagation can't hide the click;
// the rebuild itself runs afterwards (timeout), once their handlers are done.
document.addEventListener(
"click",
(e) => {
if (e.target instanceof Element && e.target.closest(selector)) {
scheduleRebuild(0);
}
},
true,
);
}
// Rebuild when the URL changes without a full page load (history API / back-forward)
function watchNavigation() {
let currentUrl = window.location.href;
const check = () => {
if (currentUrl === window.location.href) return;
currentUrl = window.location.href;
scheduleRebuild(500);
};
try {
["pushState", "replaceState"].forEach((method) => {
const original = history[method];
history[method] = function () {
const result = original.apply(this, arguments);
check();
return result;
};
});
} catch (e) {
// history is not patchable here; popstate/hashchange below still work
}
window.addEventListener("popstate", check);
window.addEventListener("hashchange", check);
}
// Rebuild on width / zoom changes (avoids the high-res thumbnail layout bug).
// Height-only resizes (mobile URL bar, devtools) don't affect the layout.
function watchResize() {
let lastWidth = window.innerWidth;
let lastDpr = window.devicePixelRatio;
window.addEventListener("resize", () => {
if (
window.innerWidth === lastWidth &&
window.devicePixelRatio === lastDpr
) {
return;
}
lastWidth = window.innerWidth;
lastDpr = window.devicePixelRatio;
scheduleRebuild(300);
});
}
// ======================================================================
// Styles
// ======================================================================
const CSS = `
@import url("https://cdnjs.cloudflare.com/ajax/libs/justifiedGallery/3.8.1/css/justifiedGallery.min.css");
/* Custom styles for Danbooru integration */
.justified-gallery {
margin: 0;
padding: 0;
}
.justified-gallery > a {
border: 2px solid transparent;
border-radius: 6px;
background: none !important;
transition: border 0.2s ease, box-shadow 0.2s ease;
}
.justified-gallery > a:hover {
border: 2px solid #faf9f6;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.justified-gallery > a > img {
border-radius: 4px;
transition: transform 0.2s ease, opacity 0.3s ease;
}
.justified-gallery > a:hover > img {
transform: scale(1.02);
}
/* Hide original gallery (unless the justified gallery failed to start) */
html:not(.DJG-failed) .post-gallery-grid .posts-container {
display: none;
}
/* Ensure proper spacing */
#posts {
padding: 10px 0px;
}
/* Caption styling */
.jg-caption {
transition: border 0.2s ease, box-shadow 0.2s ease !important;
padding: 4px !important;
font-size: 14px !important;
font-weight: 500 !important;
}
.caption-button {
opacity: 1;
cursor: pointer;
padding: 2px 4px;
border-radius: 2px;
transition: background-color 0.2s ease;
user-select: none;
}
.caption-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.score-display {
padding: 2px 2px 2px 0px;
}
.caption-button.processing {
opacity: 0.5;
}
.caption-button.processing:hover {
background-color: transparent;
}
.jg-caption .score-display.positive {
color: green;
}
.jg-caption .score-display.negative {
color: red;
}
.jg-caption .favorite-btn.active {
color: red;
}
.jg-caption .upvote-btn.active {
color: green;
}
.jg-caption .content-duration {
padding: 2px 4px;
right: 8px;
}
.jg-caption .right-side-wrapper {
position: absolute;
right: 4px;
bottom: 4px;
}
#danbooru-hover-preview {
position: fixed;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
overflow: visible;
}
#danbooru-hover-preview.visible {
opacity: 1;
}
#danbooru-hover-preview img {
display: block;
/* Size is controlled by JavaScript (native size + transform scaling) */
height: auto;
width: auto;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
border: 4px solid #faf9f6;
border-radius: 4px;
box-shadow: rgba(0, 0, 0, 0.5) 1px 1px 5px, rgba(0, 0, 0, 0.5) -1px 1px 5px,
rgba(0, 0, 0, 0.5) 1px -1px 5px, rgba(0, 0, 0, 0.5) -1px -1px 5px;
}
/* Relocated search bar */
#relocated-search-container #search-box-submit:hover {
background-color: #0052a3 !important;
}
/* Dynamic search */
#DJG-dynamic-search-btn:hover:not(:disabled) {
background-color: rgb(220, 0, 62) !important;
}
#DJG-dynamic-search-btn:disabled {
opacity: 0.6;
cursor: wait !important;
}
#DJG-toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #222;
color: #fff;
padding: 10px 15px;
border-radius: 6px;
z-index: 9999;
font-size: 14px;
}
/* Settings button + dropdown */
#DJG-settings {
position: relative;
display: inline-block;
margin-left: 10px;
vertical-align: middle;
}
#DJG-settings.floating {
position: fixed;
top: 10px;
right: 10px;
margin-left: 0;
z-index: 10001;
}
#DJG-settings-btn {
background: rgb(0, 116, 172);
color: white;
border: none;
border-radius: 4px;
padding: 3px 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
vertical-align: middle;
}
#DJG-settings-btn:hover {
background: #23598fff;
}
#DJG-settings-btn:active {
transform: scale(0.98);
}
#DJG-settings-menu {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 10001;
min-width: 200px;
padding: 4px;
background: rgba(20, 20, 20, 0.98);
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
#DJG-settings.floating #DJG-settings-menu {
left: auto;
right: 0;
}
#DJG-settings-menu.visible {
display: block;
}
.DJG-settings-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
color: #ddd;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
}
.DJG-settings-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.DJG-settings-item input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: rgb(0, 116, 172);
}
#jg-reload-btn {
background-color: rgb(0, 116, 172);
color: white;
border: none;
padding: 3px 6px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
margin-left: 10px;
transition: all 0.2s ease;
user-select: none;
vertical-align: middle;
}
#jg-reload-btn:hover {
background-color: #23598fff;
}
/* Info tooltip styles */
.info-button {
cursor: pointer;
padding: 2px 4px;
border-radius: 2px;
transition: background-color 0.2s ease;
user-select: none;
}
.info-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.post-info-tooltip {
position: absolute;
z-index: 1000;
background: rgba(20, 20, 20, 0.95);
border: 1px solid #444;
border-radius: 6px;
padding: 8px;
max-width: 200px;
min-width: 180px;
max-height: 300px; /* Enables vertical scrolling when content is too long */
overflow-y: auto; /* Scrollbar appears only when needed */
overscroll-behavior: contain; /* Don't scroll the page when the tooltip hits its end */
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
color: #fff;
font-size: 11px;
line-height: 1.3;
backdrop-filter: blur(8px);
display: none;
scrollbar-width: thin; /* For Firefox */
}
/* Optional scrollbar styling for WebKit browsers (Chrome, Edge, Safari) */
.post-info-tooltip::-webkit-scrollbar {
width: 6px;
}
.post-info-tooltip::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.post-info-tooltip.visible {
display: block;
}
.tooltip-section {
margin-bottom: 16px;
}
.tooltip-section:last-child {
margin-bottom: 0;
}
.tooltip-section h3 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: bold;
color: #ccc;
border-bottom: 1px solid #444;
padding-bottom: 4px;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.tooltip-tag {
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
text-decoration: none;
color: white;
transition: opacity 0.2s ease;
}
.tooltip-tag:hover {
opacity: 0.8;
}
.tooltip-info ul {
list-style: none;
padding: 0;
margin: 0;
}
.tooltip-info li {
margin-bottom: 6px;
font-size: 12px;
color: #ccc;
}
.tooltip-info a {
color: #66b3ff;
text-decoration: none;
}
.tooltip-info a:hover {
text-decoration: underline;
}
.tooltip-loading {
text-align: center;
color: #888;
padding: 20px;
}
.tooltip-exit-button-container {
position: sticky;
top: 0;
z-index: 1001;
}
.tooltip-exit-button {
position: absolute;
top: 0px;
right: 0px;
border: none;
background: transparent;
font-size: 20px;
font-weight: bold;
cursor: pointer;
color: #888;
padding: 0px 4px;
}
.tooltip-exit-button:hover {
color: #fff;
}
#page {
margin-left: 16px;
margin-right: 16px;
}
`;
function injectStyles() {
if (document.getElementById("DJG-style")) return;
const style = document.createElement("style");
style.id = "DJG-style";
style.textContent = CSS;
document.head.appendChild(style);
}
// ======================================================================
// Init
// ======================================================================
function init() {
// Guard against running twice
if (initialized) return;
initialized = true;
// Prevent error message by defining resetImgSrc as a no-op on images
if (
typeof HTMLImageElement !== "undefined" &&
!HTMLImageElement.prototype.resetImgSrc
) {
HTMLImageElement.prototype.resetImgSrc = function () {};
}
injectStyles();
// Hide sidebar (controlled by setting, toggled live via the Settings menu)
applySidebarSetting();
// Reload button + Settings button/dropdown
createReloadButton();
createSettingsMenu();
// Main functionality
buildGallery();
// Global triggers (all registered exactly once)
setupResetHandlers();
setupTooltipHandlers();
watchNavigation();
watchResize();
window.addEventListener("blur", () => hoverPreview.stop());
// Dynamic search button (before relocating, so the original icon is cloned unstyled)
setupDynamicSearch();
// Infinite scroll (after the gallery exists, so it can wait for its layout)
setupInfiniteScroll();
// Relocate search bar
if (CONFIG.enableRelocateSearchBar) relocateSearchBar();
}
// Initialize when DOM is ready (short delay lets Danbooru's own scripts finish first)
const start = () => setTimeout(init, 100);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
})();