Adds direct image links, and displays Google Images results in an in-page gallery.
// ==UserScript==
// @name Google Image Gallery
// @namespace https://greasyfork.org/
// @version 1.3
// @author Bui Quoc Dung
// @description Adds direct image links, and displays Google Images results in an in-page gallery.
// @include /^https:\/\/www\.google\.(co\.)?[a-z]{2,3}\/search.*(tbm=isch|udm=2)/
// @include /^https:\/\/www\.google\.com(\.[a-z]{2,3})?\/search.*(tbm=isch|udm=2)/
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-start
// ==/UserScript==
(() => {
"use strict";
// ===========================================================================
// 01. Configuration
// ===========================================================================
// ---------------------------------------------------------------------------
// 01.1 Google Images native configuration
// ---------------------------------------------------------------------------
const pageElements = {
selectors: {
resultContainer: "#rso div[data-q]",
imageWrapper: "div[data-snm] div[data-img-wrapper]",
thumbnail: "img",
jsdataHost: "[jsdata]",
jsController: "div[jscontroller]",
inlineScript: "script:not([src])"
},
attributes: {
jsdata: "jsdata"
}
};
// ---------------------------------------------------------------------------
// 01.2 Userscript configuration
// ---------------------------------------------------------------------------
const injectedElements = {
selectors: {
panel: ".panel_gidlp"
},
attributes: {
originalWidth: "ow",
originalHeight: "oh",
availability: "availability",
thumbnailUrl: "thumbnailUrl",
sourcePageUrl: "sourcePageUrl",
sourcePageTitle: "sourcePageTitle"
},
classes: {
processed: "paneled_gidlp",
panel: "panel_gidlp",
waiting: "waiting_gidlp",
checking: "checking_gidlp",
available: "available_gidlp",
unavailable: "unavailable_gidlp",
gallery: "gallery_gidlp",
galleryVisible: "gallery_visible_gidlp",
sidebar: "gallery_sidebar_gidlp",
sidebarHeader: "gallery_sidebar_header_gidlp",
sidebarList: "gallery_sidebar_list_gidlp",
thumbnailItem: "gallery_thumbnail_item_gidlp",
thumbnailActive: "gallery_thumbnail_active_gidlp",
thumbnailUnavailable: "gallery_thumbnail_unavailable_gidlp",
thumbnailMedia: "gallery_thumbnail_media_gidlp",
thumbnailImage: "gallery_thumbnail_image_gidlp",
thumbnailInfo: "gallery_thumbnail_info_gidlp",
thumbnailIndex: "gallery_thumbnail_index_gidlp",
thumbnailDimension: "gallery_thumbnail_dimension_gidlp",
viewer: "gallery_viewer_gidlp",
viewerTitle: "gallery_viewer_title_gidlp",
imageStage: "gallery_image_stage_gidlp",
mainImage: "gallery_main_image_gidlp",
imageLoaded: "gallery_image_loaded_gidlp",
loading: "gallery_loading_gidlp",
error: "gallery_error_gidlp",
navigationButton: "gallery_navigation_button_gidlp",
previousButton: "gallery_previous_button_gidlp",
nextButton: "gallery_next_button_gidlp",
closeButton: "gallery_close_button_gidlp",
counter: "gallery_counter_gidlp"
}
};
// ---------------------------------------------------------------------------
// 01.3 Runtime configuration and messages
// ---------------------------------------------------------------------------
const siteConfig = {
request: {
searchPath: "/search?",
timeout: 10000,
maxConcurrent: 6,
retryDelay: 100,
processDelay: 500
},
intersection: {
root: null,
rootMargin: "400px 0px",
threshold: 0
},
messages: {
waiting: "Waiting to check image availability",
checking: "Checking image availability",
available: "Open image gallery",
unavailable: "This image may no longer be available",
loading: "Loading image...",
error: "Unable to display this image",
galleryTitle: "Google Images",
previous: "Previous image",
next: "Next image",
close: "Close gallery",
sourcePage: "Open source page"
},
dataReaders: {
initial(script) {
const match = script.textContent.match(/var [a-z]=(\{".*?\});/);
if (!match) {
return null;
}
try {
return JSON.parse(match[1]);
} catch {
return null;
}
},
cached(wrapper, cache) {
const jsdata = wrapper.closest(pageElements.selectors.jsdataHost)
?.getAttribute(pageElements.attributes.jsdata);
const key = jsdata?.match(/[^;]+$/)?.[0];
return key ? cache?.[key]?.[1]?.[3] : null;
},
controller(wrapper) {
return wrapper.closest(pageElements.selectors.jsController)
?.__jscontroller?.pending?.value?.Vz?.Bp?.[1]?.Bp?.[3];
}
}
};
// ---------------------------------------------------------------------------
// 01.4 CSS styles
// ---------------------------------------------------------------------------
const styles = `
/* --------------------------------------------------------------------------
* 01.4.1 Google Images dimension styles
* -------------------------------------------------------------------------- */
${pageElements.selectors.imageWrapper} {
position: relative;
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.panel} {
position: absolute;
right: 0;
bottom: 0;
z-index: 10;
display: block;
font-size: 10pt;
line-height: normal;
opacity: 1;
transition:
opacity 0.2s ease,
filter 0.2s ease;
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.panel}::before {
display: block;
padding: 2px 6px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.82);
color: #fff;
font: 600 12px/1.2 Arial, sans-serif;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
content:
attr(data-${injectedElements.attributes.originalWidth})
"×"
attr(data-${injectedElements.attributes.originalHeight});
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.panel}:visited::before {
color: #f9f;
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.waiting} {
opacity: 1;
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.checking} {
opacity: 0.72;
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.available} {
opacity: 1;
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.unavailable} {
opacity: 0.42;
filter: grayscale(1);
}
${pageElements.selectors.imageWrapper} > .${injectedElements.classes.unavailable}::before {
text-decoration: line-through;
}
/* --------------------------------------------------------------------------
* 01.4.2 Gallery styles
* -------------------------------------------------------------------------- */
.${injectedElements.classes.gallery} {
position: fixed;
inset: 0;
z-index: 2147483646;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
visibility: hidden;
opacity: 0;
pointer-events: none;
background-color: transparent;
transition:
opacity 0.2s ease,
visibility 0.2s ease;
}
.${injectedElements.classes.gallery}.${injectedElements.classes.galleryVisible} {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.${injectedElements.classes.sidebar} {
display: flex;
flex-direction: column;
min-width: 0;
height: 100vh;
overflow: hidden;
background-color: rgba(18, 18, 18, 0.97);
border-right: 1px solid rgba(255, 255, 255, 0.16);
box-shadow: 8px 0 28px rgba(0, 0, 0, 0.4);
}
.${injectedElements.classes.sidebarHeader} {
flex: 0 0 auto;
padding: 18px 16px;
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.13);
font: 600 20px/1.3 Arial, sans-serif;
}
.${injectedElements.classes.sidebarList} {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
min-height: 0;
padding: 10px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
touch-action: pan-y;
scrollbar-width: thin;
scrollbar-color:
rgba(255, 255, 255, 0.45)
rgba(255, 255, 255, 0.08);
}
.${injectedElements.classes.sidebarList}::-webkit-scrollbar {
width: 10px;
}
.${injectedElements.classes.sidebarList}::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.07);
}
.${injectedElements.classes.sidebarList}::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background-color: rgba(255, 255, 255, 0.42);
background-clip: padding-box;
}
.${injectedElements.classes.thumbnailItem} {
display: block;
flex: 0 0 auto;
width: 100%;
min-width: 0;
padding: 0;
overflow: hidden;
border: 2px solid transparent;
border-radius: 12px;
background-color: rgba(255, 255, 255, 0.07);
text-align: left;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
opacity 0.15s ease,
box-shadow 0.15s ease;
}
.${injectedElements.classes.thumbnailMedia} {
position: relative;
display: block;
width: 100%;
min-height: 90px;
overflow: hidden;
cursor: pointer;
}
.${injectedElements.classes.thumbnailItem}:hover {
background-color: rgba(255, 255, 255, 0.14);
}
.${injectedElements.classes.thumbnailItem}.${injectedElements.classes.thumbnailActive} {
border-color: #8ab4f8;
box-shadow:
0 0 0 1px rgba(138, 180, 248, 0.45),
0 3px 14px rgba(0, 0, 0, 0.35);
}
.${injectedElements.classes.thumbnailItem}.${injectedElements.classes.thumbnailUnavailable} {
opacity: 0.38;
filter: grayscale(1);
}
.${injectedElements.classes.thumbnailImage} {
display: block;
width: 100%;
height: 100%;
min-height: 90px;
max-height: 260px;
object-fit: contain;
background-color: rgba(255, 255, 255, 0.08);
}
.${injectedElements.classes.thumbnailInfo} {
position: absolute;
left: 5px;
right: 5px;
bottom: 5px;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
pointer-events: none;
}
.${injectedElements.classes.thumbnailIndex},
.${injectedElements.classes.thumbnailDimension} {
display: inline-block;
max-width: 100%;
padding: 3px 6px;
overflow: hidden;
border-radius: 4px;
color: #f1f3f4;
background-color: rgba(0, 0, 0, 0.68);
font: 600 12px/1.2 Arial, sans-serif;
text-overflow: ellipsis;
white-space: nowrap;
}
.${injectedElements.classes.thumbnailIndex} {
flex: 0 0 auto;
}
.${injectedElements.classes.thumbnailDimension} {
flex: 0 1 auto;
}
.${injectedElements.classes.thumbnailItem}.${injectedElements.classes.thumbnailUnavailable}
.${injectedElements.classes.thumbnailIndex},
.${injectedElements.classes.thumbnailItem}.${injectedElements.classes.thumbnailUnavailable}
.${injectedElements.classes.thumbnailDimension} {
opacity: 0.55;
text-decoration: line-through;
}
.${injectedElements.classes.viewer} {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-width: 0;
height: 100vh;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.58);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.${injectedElements.classes.viewerTitle} {
display: -webkit-box;
flex: 0 0 auto;
width: min(900px, calc(100% - 180px));
max-height: 44px;
overflow: hidden;
color: #f1f3f4;
font: 600 16px/1.35 Arial, sans-serif;
text-align: center;
text-decoration: none;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.${injectedElements.classes.viewerTitle}:hover {
color: #8ab4f8;
text-decoration: underline;
}
.${injectedElements.classes.imageStage} {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: calc(100% - 150px);
height: calc(100% - 128px);
overflow: auto;
}
.${injectedElements.classes.mainImage} {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
opacity: 0;
cursor: default;
user-select: none;
filter: drop-shadow(0 12px 40px rgba(0, 0, 0, 0.55));
transition: opacity 0.2s ease;
}
.${injectedElements.classes.mainImage}.${injectedElements.classes.imageLoaded} {
opacity: 1;
}
.${injectedElements.classes.loading},
.${injectedElements.classes.error} {
position: absolute;
max-width: 320px;
padding: 12px 16px;
border-radius: 8px;
color: #fff;
background-color: rgba(0, 0, 0, 0.7);
font: 16px/1.4 Arial, sans-serif;
text-align: center;
pointer-events: none;
}
.${injectedElements.classes.error} {
display: none;
}
.${injectedElements.classes.navigationButton},
.${injectedElements.classes.closeButton} {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
color: #fff;
background-color: rgba(0, 0, 0, 0.45);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
cursor: pointer;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition:
background-color 0.15s ease,
opacity 0.15s ease,
transform 0.15s ease;
}
.${injectedElements.classes.navigationButton}:hover,
.${injectedElements.classes.closeButton}:hover {
background-color: rgba(0, 0, 0, 0.78);
}
.${injectedElements.classes.navigationButton} {
top: 50%;
width: 48px;
height: 72px;
border-radius: 12px;
font: 36px/1 Arial, sans-serif;
transform: translateY(-50%);
}
.${injectedElements.classes.navigationButton}:active {
transform: translateY(-50%) scale(0.94);
}
.${injectedElements.classes.previousButton} {
left: 16px;
}
.${injectedElements.classes.nextButton} {
right: 16px;
}
.${injectedElements.classes.closeButton} {
top: 16px;
right: 16px;
width: 42px;
height: 42px;
border-radius: 50%;
font: 29px/1 Arial, sans-serif;
}
.${injectedElements.classes.closeButton}:active {
transform: scale(0.94);
}
.${injectedElements.classes.counter} {
position: absolute;
left: 50%;
bottom: 15px;
z-index: 2;
padding: 6px 11px;
border-radius: 999px;
color: #fff;
background-color: rgba(0, 0, 0, 0.55);
font: 14px/1.2 Arial, sans-serif;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transform: translateX(-50%);
}
@media (max-width: 900px) {
.${injectedElements.classes.gallery} {
grid-template-columns: 210px minmax(0, 1fr);
}
}
@media (max-width: 620px) {
.${injectedElements.classes.gallery} {
grid-template-columns: 110px minmax(0, 1fr);
}
.${injectedElements.classes.sidebarHeader} {
padding: 14px 10px;
font-size: 16px;
}
.${injectedElements.classes.sidebarList} {
gap: 8px;
padding: 7px;
}
.${injectedElements.classes.thumbnailInfo} {
right: 3px;
bottom: 3px;
padding: 2px 4px;
font-size: 11px;
}
.${injectedElements.classes.imageStage} {
width: calc(100% - 92px);
}
.${injectedElements.classes.navigationButton} {
width: 38px;
height: 58px;
}
.${injectedElements.classes.previousButton} {
left: 7px;
}
.${injectedElements.classes.nextButton} {
right: 7px;
}
}
`;
// ===========================================================================
// 02. DOM Elements and State
// ===========================================================================
// ---------------------------------------------------------------------------
// 02.1 Google Images native element references
// ---------------------------------------------------------------------------
let resultContainer = null;
// ---------------------------------------------------------------------------
// 02.2 Userscript state
// ---------------------------------------------------------------------------
const availabilityCache = new Map();
const pendingUrls = new Set();
const checkQueue = [];
let imageDataCache = null;
let activeChecks = 0;
let processTimer = null;
let galleryItems = [];
let currentGalleryIndex = -1;
let bodyOverflowBeforeGallery = "";
GM_addStyle(styles);
const visibilityObserver = new IntersectionObserver(handleVisibilityChanges, siteConfig.intersection);
// ---------------------------------------------------------------------------
// 02.3 Userscript-created gallery elements
// ---------------------------------------------------------------------------
const gallery = document.createElement("div");
const gallerySidebar = document.createElement("aside");
const sidebarHeader = document.createElement("div");
const sidebarList = document.createElement("div");
const galleryViewer = document.createElement("main");
const viewerTitle = document.createElement("a");
const imageStage = document.createElement("div");
const mainImage = document.createElement("img");
const loadingMessage = document.createElement("div");
const errorMessage = document.createElement("div");
const previousButton = document.createElement("button");
const nextButton = document.createElement("button");
const closeButton = document.createElement("button");
const counter = document.createElement("div");
gallery.className = injectedElements.classes.gallery;
gallerySidebar.className = injectedElements.classes.sidebar;
sidebarHeader.className = injectedElements.classes.sidebarHeader;
sidebarList.className = injectedElements.classes.sidebarList;
galleryViewer.className = injectedElements.classes.viewer;
viewerTitle.className = injectedElements.classes.viewerTitle;
imageStage.className = injectedElements.classes.imageStage;
mainImage.className = injectedElements.classes.mainImage;
loadingMessage.className = injectedElements.classes.loading;
errorMessage.className = injectedElements.classes.error;
previousButton.className = `${injectedElements.classes.navigationButton} ${injectedElements.classes.previousButton}`;
nextButton.className = `${injectedElements.classes.navigationButton} ${injectedElements.classes.nextButton}`;
closeButton.className = injectedElements.classes.closeButton;
counter.className = injectedElements.classes.counter;
sidebarHeader.textContent = siteConfig.messages.galleryTitle;
loadingMessage.textContent = siteConfig.messages.loading;
errorMessage.textContent = siteConfig.messages.error;
viewerTitle.target = "_blank";
viewerTitle.rel = "noopener noreferrer";
viewerTitle.setAttribute("aria-label", siteConfig.messages.sourcePage);
viewerTitle.style.display = "none";
previousButton.type = "button";
nextButton.type = "button";
closeButton.type = "button";
previousButton.textContent = "‹";
nextButton.textContent = "›";
closeButton.textContent = "×";
previousButton.title = siteConfig.messages.previous;
nextButton.title = siteConfig.messages.next;
closeButton.title = siteConfig.messages.close;
previousButton.setAttribute("aria-label", siteConfig.messages.previous);
nextButton.setAttribute("aria-label", siteConfig.messages.next);
closeButton.setAttribute("aria-label", siteConfig.messages.close);
imageStage.append(mainImage, loadingMessage, errorMessage);
gallerySidebar.append(sidebarHeader, sidebarList);
galleryViewer.append(viewerTitle, imageStage, previousButton, nextButton, closeButton, counter);
gallery.append(gallerySidebar, galleryViewer);
// ===========================================================================
// 03. Shared Helpers
// ===========================================================================
// ---------------------------------------------------------------------------
// 03.1 URL and link helpers
// ---------------------------------------------------------------------------
function isValidImageUrl(url) {
if (!url) {
return false;
}
try {
const parsedUrl = new URL(url, location.href);
return (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
} catch {
return false;
}
}
function getLinkUrl(link) {
return link.getAttribute("href") || "";
}
function isValidPageUrl(url) {
if (!url) {
return false;
}
try {
const parsedUrl = new URL(url, location.href);
return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
} catch {
return false;
}
}
function normalizeSourcePageUrl(url) {
if (!isValidPageUrl(url)) {
return "";
}
try {
const parsedUrl = new URL(url, location.href);
if (parsedUrl.hostname.includes("google.") && parsedUrl.pathname === "/url") {
return parsedUrl.searchParams.get("url") || parsedUrl.searchParams.get("q") || "";
}
return parsedUrl.href;
} catch {
return "";
}
}
function getSourcePageData(wrapper) {
const resultCard = wrapper?.closest("div[data-snm]") || wrapper?.parentElement;
if (!resultCard) {
return {url: "", title: ""};
}
const anchors = Array.from(resultCard.querySelectorAll("a[href]"));
const sourceAnchor = anchors.find(anchor => {
if (anchor.classList.contains(injectedElements.classes.panel)) {
return false;
}
const url = normalizeSourcePageUrl(anchor.getAttribute("href") || "");
if (!isValidPageUrl(url)) {
return false;
}
try {
return !new URL(url).hostname.includes("google.");
} catch {
return false;
}
});
if (!sourceAnchor) {
return {url: "", title: ""};
}
const url = normalizeSourcePageUrl(sourceAnchor.getAttribute("href") || "");
const title =
sourceAnchor.getAttribute("aria-label")?.trim() ||
sourceAnchor.getAttribute("title")?.trim() ||
sourceAnchor.textContent?.replace(/\s+/g, " ").trim() ||
"";
return {url, title};
}
function shouldUseNativeLinkBehavior(event) {
return (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey);
}
// ---------------------------------------------------------------------------
// 03.2 Network response helpers
// ---------------------------------------------------------------------------
function parseContentType(responseHeaders) {
return responseHeaders?.match(/^content-type:\s*([^\r\n]+)/im)?.[1]?.trim()?.toLowerCase() || "";
}
function isImageResponse(response) {
const statusValid = response.status >= 200 && response.status < 400;
if (!statusValid) {
return false;
}
const contentType = parseContentType(response.responseHeaders);
return (!contentType || contentType.startsWith("image/") || contentType.includes("octet-stream"));
}
// ===========================================================================
// 04. Google Images Dimension Mode
// ===========================================================================
// ---------------------------------------------------------------------------
// 04.1 Availability state management
// ---------------------------------------------------------------------------
function setAvailability(link, state) {
link.classList.remove(
injectedElements.classes.waiting,
injectedElements.classes.checking, injectedElements.classes.available, injectedElements.classes.unavailable);
link.classList.add(injectedElements.classes[state]);
link.dataset[injectedElements.attributes.availability] = state;
link.title = siteConfig.messages[state];
}
function applyAvailabilityToMatchingLinks(
url,
available
) {
document
.querySelectorAll(injectedElements.selectors.panel)
.forEach(link => {
if (getLinkUrl(link) !== url) {
return;
}
visibilityObserver.unobserve(link);
setAvailability(link, available ? "available" : "unavailable");
});
galleryItems.forEach(item => {
if (item.url === url) {
item.available = available;
}
});
sidebarList
.querySelectorAll(
`.${injectedElements.classes.thumbnailItem}`
)
.forEach((thumbnail, index) => {
const item = galleryItems[index];
if (!item || item.url !== url) {
return;
}
thumbnail.classList.toggle(injectedElements.classes.thumbnailUnavailable, !available);
});
}
function finishAvailabilityCheck(url, available) {
availabilityCache.set(url, available);
pendingUrls.delete(url);
applyAvailabilityToMatchingLinks(url, available);
activeChecks = Math.max(0, activeChecks - 1);
runCheckQueue();
}
// ---------------------------------------------------------------------------
// 04.2 Availability requests and queue
// ---------------------------------------------------------------------------
function checkUrl(url) {
GM_xmlhttpRequest({
method: "GET",
url,
headers: {
Range: "bytes=0-0"
},
responseType: "blob",
timeout: siteConfig.request.timeout,
anonymous: true,
onload(response) {
finishAvailabilityCheck(url, isImageResponse(response));
},
onerror() {
finishAvailabilityCheck(url, false);
},
ontimeout() {
finishAvailabilityCheck(url, false);
},
onabort() {
finishAvailabilityCheck(url, false);
}
});
}
function runCheckQueue() {
while (
activeChecks <
siteConfig.request.maxConcurrent &&
checkQueue.length > 0
) {
const url = checkQueue.shift();
if (availabilityCache.has(url)) {
pendingUrls.delete(url);
applyAvailabilityToMatchingLinks(url, availabilityCache.get(url));
continue;
}
activeChecks++;
checkUrl(url);
}
}
function queueAvailabilityCheck(link) {
const url = getLinkUrl(link);
if (!isValidImageUrl(url)) {
visibilityObserver.unobserve(link);
setAvailability(link, "unavailable");
return;
}
if (availabilityCache.has(url)) {
visibilityObserver.unobserve(link);
setAvailability(link, availabilityCache.get(url) ? "available" : "unavailable");
return;
}
setAvailability(link, "checking");
if (pendingUrls.has(url)) {
return;
}
pendingUrls.add(url);
checkQueue.push(url);
runCheckQueue();
}
function handleVisibilityChanges(entries) {
entries.forEach(entry => {
if (!entry.isIntersecting) {
return;
}
visibilityObserver.unobserve(entry.target);
queueAvailabilityCheck(entry.target);
});
}
function observeAvailability(link) {
const url = getLinkUrl(link);
if (!isValidImageUrl(url)) {
visibilityObserver.unobserve(link);
setAvailability(link, "unavailable");
return;
}
if (availabilityCache.has(url)) {
visibilityObserver.unobserve(link);
setAvailability(link, availabilityCache.get(url) ? "available" : "unavailable");
return;
}
setAvailability(link, "waiting");
visibilityObserver.observe(link);
}
// ---------------------------------------------------------------------------
// 04.3 Dimension panel creation and link configuration
// ---------------------------------------------------------------------------
function configureLink(
link,
imageData,
wrapper
) {
const imageUrl = imageData?.[0];
const previousUrl = getLinkUrl(link);
link.rel = "nofollow noopener noreferrer";
link.dataset[injectedElements.attributes.originalWidth] = imageData?.[2] || "???";
link.dataset[injectedElements.attributes.originalHeight] = imageData?.[1] || "???";
link.dataset[injectedElements.attributes.thumbnailUrl] = getThumbnailUrl(wrapper);
const sourcePage = getSourcePageData(wrapper);
link.dataset[injectedElements.attributes.sourcePageUrl] = sourcePage.url;
link.dataset[injectedElements.attributes.sourcePageTitle] = sourcePage.title;
if (isValidImageUrl(imageUrl)) {
if (previousUrl !== imageUrl) {
visibilityObserver.unobserve(link);
link.classList.remove(
injectedElements.classes.waiting,
injectedElements.classes.checking, injectedElements.classes.available, injectedElements.classes.unavailable);
delete link.dataset[injectedElements.attributes.availability];
}
link.href = imageUrl;
observeAvailability(link);
} else {
visibilityObserver.unobserve(link);
link.removeAttribute("href");
setAvailability(link, "unavailable");
}
if (!link.dataset.clickHandlerAdded) {
link.dataset.clickHandlerAdded = "true";
link.addEventListener("click", handlePanelClick, true);
}
}
function createPanel(wrapper) {
let link = wrapper.querySelector(injectedElements.selectors.panel);
if (link) {
return link;
}
link = document.createElement("a");
link.className = injectedElements.classes.panel;
wrapper.insertBefore(link, wrapper.firstChild);
return link;
}
// ---------------------------------------------------------------------------
// 04.4 Google Images result processing
// ---------------------------------------------------------------------------
function processCachedEntry(wrapper) {
const imageData = siteConfig.dataReaders.cached(wrapper, imageDataCache);
if (!imageData) {
waitForImageData(wrapper);
return;
}
wrapper.classList.add(injectedElements.classes.processed);
delete wrapper.dataset.dimensionWaiting;
const link = createPanel(wrapper);
configureLink(link, imageData, wrapper);
}
function waitForImageData(wrapper) {
if (!wrapper.isConnected || wrapper.classList.contains(injectedElements.classes.processed)) {
return;
}
if (wrapper.dataset.dimensionWaiting === "true") {
return;
}
wrapper.dataset.dimensionWaiting = "true";
function retry() {
if (!wrapper.isConnected) {
delete wrapper.dataset.dimensionWaiting;
return;
}
if (wrapper.classList.contains(injectedElements.classes.processed)) {
delete wrapper.dataset.dimensionWaiting;
return;
}
const imageData =
siteConfig.dataReaders.cached(wrapper, imageDataCache) ||
siteConfig.dataReaders.controller(wrapper);
if (imageData) {
wrapper.classList.add(injectedElements.classes.processed);
delete wrapper.dataset.dimensionWaiting;
const link = createPanel(wrapper);
configureLink(link, imageData, wrapper);
return;
}
setTimeout(retry, siteConfig.request.retryDelay);
}
retry();
}
function processEntries() {
resultContainer = document.querySelector(pageElements.selectors.resultContainer) || resultContainer;
const selector = `${pageElements.selectors.imageWrapper}:not(.${injectedElements.classes.processed})`;
document.querySelectorAll(selector).forEach(wrapper => {
const imageData =
siteConfig.dataReaders.cached(wrapper, imageDataCache) ||
siteConfig.dataReaders.controller(wrapper);
if (imageData) {
wrapper.classList.add(injectedElements.classes.processed);
delete wrapper.dataset.dimensionWaiting;
const link = createPanel(wrapper);
configureLink(link, imageData, wrapper);
} else {
waitForImageData(wrapper);
}
});
if (gallery.classList.contains(injectedElements.classes.galleryVisible)) {
refreshGalleryItems();
}
}
function scheduleEntryProcessing() {
clearTimeout(processTimer);
processTimer = setTimeout(processEntries, siteConfig.request.processDelay);
}
// ---------------------------------------------------------------------------
// 04.5 Google Images data extraction
// ---------------------------------------------------------------------------
function interceptSearchResponses() {
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open =
function(method, url) {
if (
typeof url === "string" &&
url.startsWith(
siteConfig.request.searchPath
)
) {
this.addEventListener(
"load",
() => {
const responseParts = this.responseText.split(";[");
if (!responseParts.length) {
return;
}
try {
const responseData = JSON.parse(`[${responseParts.at(-1)}`)[0];
if (!imageDataCache) {
imageDataCache = {};
}
responseData.forEach(
item => {
try {
imageDataCache[item[0]] = JSON.parse(item[1]);
} catch {}
}
);
scheduleEntryProcessing();
} catch {}
}
);
}
return originalOpen.apply(this, arguments);
};
}
function readInitialImageData() {
document
.querySelectorAll(
pageElements.selectors.inlineScript
)
.forEach(script => {
if (imageDataCache) {
return;
}
imageDataCache = siteConfig.dataReaders.initial(script);
});
}
// ---------------------------------------------------------------------------
// 04.6 Dimension mode initialization
// ---------------------------------------------------------------------------
function observeResults() {
resultContainer = document.querySelector(pageElements.selectors.resultContainer);
const mutationObserver = new MutationObserver(() => {
const latestContainer = document.querySelector(pageElements.selectors.resultContainer);
if (latestContainer) {
resultContainer = latestContainer;
}
scheduleEntryProcessing();
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true
});
processEntries();
}
function initialize() {
mountGallery();
readInitialImageData();
observeResults();
}
// ===========================================================================
// 05. Gallery Mode
// ===========================================================================
// ---------------------------------------------------------------------------
// 05.1 Gallery mounting and thumbnail data
// ---------------------------------------------------------------------------
function mountGallery() {
if (document.body && !gallery.isConnected) {
document.body.append(gallery);
}
}
function getThumbnailUrl(wrapper) {
if (!wrapper) {
return "";
}
const images = Array.from(wrapper.querySelectorAll(pageElements.selectors.thumbnail));
const candidates = images
.map(image => {
const url =
image.currentSrc ||
image.getAttribute("src") ||
image.getAttribute("data-src") || image.getAttribute("data-iurl") || "";
const rect = image.getBoundingClientRect();
const style = getComputedStyle(image);
return {
image,
url,
visible:
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) > 0 &&
rect.width > 1 &&
rect.height > 1,
loaded:
image.naturalWidth > 1 ||
image.naturalHeight > 1, area: Math.max(rect.width, 0) * Math.max(rect.height, 0) };
})
.filter(candidate =>
isValidImageUrl(candidate.url)
)
.sort((a, b) => {
if (a.visible !== b.visible) {
return Number(b.visible) - Number(a.visible);
}
if (a.loaded !== b.loaded) {
return Number(b.loaded) - Number(a.loaded);
}
return b.area - a.area;
});
return candidates[0]?.url || "";
}
function getCurrentThumbnailUrl(link) {
const wrapper = link.closest(pageElements.selectors.imageWrapper);
const freshThumbnailUrl = getThumbnailUrl(wrapper);
if (freshThumbnailUrl) {
link.dataset[injectedElements.attributes.thumbnailUrl] = freshThumbnailUrl;
}
return (freshThumbnailUrl || link.dataset[injectedElements.attributes.thumbnailUrl] || "");
}
function collectGalleryItems() {
const seenUrls = new Set();
const items = [];
document
.querySelectorAll(injectedElements.selectors.panel)
.forEach(link => {
const url = getLinkUrl(link);
if (
!isValidImageUrl(url) ||
seenUrls.has(url)
) {
return;
}
seenUrls.add(url);
items.push({
url,
thumbnailUrl:
getCurrentThumbnailUrl(link),
width:
link.dataset[
injectedElements.attributes.originalWidth
] || "???",
height:
link.dataset[
injectedElements.attributes.originalHeight
] || "???",
sourcePageUrl:
link.dataset[
injectedElements.attributes.sourcePageUrl
] || "",
sourcePageTitle:
link.dataset[
injectedElements.attributes.sourcePageTitle
] || "",
available: availabilityCache.get(url),
link
});
});
return items;
}
// ---------------------------------------------------------------------------
// 05.2 Gallery sidebar rendering
// ---------------------------------------------------------------------------
function createThumbnailItem(item, index) {
const card = document.createElement("div");
const media = document.createElement("div");
const image = document.createElement("img");
const info = document.createElement("span");
const indexBadge = document.createElement("span");
const dimensionBadge = document.createElement("span");
card.className = injectedElements.classes.thumbnailItem;
card.title = `${index + 1}. ${item.width} × ${item.height}`;
media.className = injectedElements.classes.thumbnailMedia;
image.className = injectedElements.classes.thumbnailImage;
info.className = injectedElements.classes.thumbnailInfo;
indexBadge.className = injectedElements.classes.thumbnailIndex;
dimensionBadge.className = injectedElements.classes.thumbnailDimension;
image.alt = "";
image.loading = "lazy";
image.draggable = false;
if (item.thumbnailUrl) {
image.src = item.thumbnailUrl;
}
image.addEventListener("error", () => {
const freshThumbnailUrl = getCurrentThumbnailUrl(item.link);
if (freshThumbnailUrl && freshThumbnailUrl !== image.getAttribute("src")) {
image.src = freshThumbnailUrl;
}
});
const numericWidth = Number(item.width);
const numericHeight = Number(item.height);
if (Number.isFinite(numericWidth) && Number.isFinite(numericHeight) && numericWidth > 0 && numericHeight > 0) {
media.style.aspectRatio = `${numericWidth} / ${numericHeight}`;
}
indexBadge.textContent = String(index + 1);
dimensionBadge.textContent = `${item.width} × ${item.height}`;
info.append(indexBadge, dimensionBadge);
if (index === currentGalleryIndex) {
card.classList.add(injectedElements.classes.thumbnailActive);
}
if (item.available === false) {
card.classList.add(injectedElements.classes.thumbnailUnavailable);
}
media.addEventListener("click", () => {
showGalleryItem(index);
});
media.append(image, info);
card.append(media);
return card;
}
function renderGallerySidebar(preserveScrollPosition = false) {
const previousScrollTop = preserveScrollPosition ? sidebarList.scrollTop : 0;
sidebarList.replaceChildren();
galleryItems.forEach((item, index) => {
sidebarList.append(createThumbnailItem(item, index));
});
if (preserveScrollPosition) {
sidebarList.scrollTop = previousScrollTop;
}
}
function refreshGalleryItems() {
const currentUrl = galleryItems[currentGalleryIndex]?.url || "";
galleryItems = collectGalleryItems();
if (currentUrl) {
const newIndex = galleryItems.findIndex(item => item.url === currentUrl);
if (newIndex >= 0) {
currentGalleryIndex = newIndex;
}
}
renderGallerySidebar(true);
updateGalleryCounter();
}
// ---------------------------------------------------------------------------
// 05.3 Gallery selection and navigation state
// ---------------------------------------------------------------------------
function updateGalleryCounter() {
if (
currentGalleryIndex < 0 ||
!galleryItems.length
) {
counter.textContent = "0 / 0";
return;
}
counter.textContent = `${currentGalleryIndex + 1} / ` + `${galleryItems.length}`;
}
function updateActiveThumbnail() {
sidebarList
.querySelectorAll(
`.${injectedElements.classes.thumbnailItem}`
)
.forEach((item, index) => {
item.classList.toggle(injectedElements.classes.thumbnailActive, index === currentGalleryIndex);
});
}
function revealActiveThumbnail() {
const activeThumbnail = sidebarList.querySelector(`.${injectedElements.classes.thumbnailActive}`);
activeThumbnail?.scrollIntoView({
block: "nearest",
behavior: "auto"
});
}
// ---------------------------------------------------------------------------
// 05.4 Gallery viewer controls
// ---------------------------------------------------------------------------
function showGalleryItem(index, revealThumbnail = false) {
if (!galleryItems.length) {
return;
}
const normalizedIndex = (index + galleryItems.length) % galleryItems.length;
const item = galleryItems[normalizedIndex];
currentGalleryIndex = normalizedIndex;
if (isValidPageUrl(item.sourcePageUrl)) {
viewerTitle.href = item.sourcePageUrl;
viewerTitle.textContent = item.sourcePageTitle || item.sourcePageUrl;
viewerTitle.title = item.sourcePageUrl;
viewerTitle.style.display = "-webkit-box";
viewerTitle.style.pointerEvents = "auto";
viewerTitle.style.opacity = "1";
} else if (item.sourcePageTitle) {
viewerTitle.removeAttribute("href");
viewerTitle.textContent = item.sourcePageTitle;
viewerTitle.title = "";
viewerTitle.style.display = "-webkit-box";
viewerTitle.style.pointerEvents = "none";
viewerTitle.style.opacity = "0.65";
} else {
viewerTitle.removeAttribute("href");
viewerTitle.textContent = "";
viewerTitle.title = "";
viewerTitle.style.display = "none";
}
mainImage.classList.remove(injectedElements.classes.imageLoaded);
loadingMessage.style.display = "";
errorMessage.style.display = "none";
mainImage.removeAttribute("src");
requestAnimationFrame(() => {
mainImage.src = item.url;
});
updateGalleryCounter();
updateActiveThumbnail();
if (revealThumbnail) {
revealActiveThumbnail();
}
}
function showPreviousImage() {
if (currentGalleryIndex <= 0) {
return;
}
showGalleryItem(currentGalleryIndex - 1, true);
}
function showNextImage() {
if (
currentGalleryIndex >=
galleryItems.length - 1
) {
return;
}
showGalleryItem(currentGalleryIndex + 1, true);
}
// ---------------------------------------------------------------------------
// 05.5 Gallery lifecycle
// ---------------------------------------------------------------------------
function openGallery(url) {
mountGallery();
galleryItems = collectGalleryItems();
currentGalleryIndex = galleryItems.findIndex(item => item.url === url);
if (currentGalleryIndex < 0) {
return;
}
renderGallerySidebar();
bodyOverflowBeforeGallery = document.body.style.overflow;
document.body.style.overflow = "hidden";
gallery.classList.add(injectedElements.classes.galleryVisible);
showGalleryItem(currentGalleryIndex);
revealActiveThumbnail();
document.addEventListener("keydown", handleGalleryKeydown);
}
function closeGallery() {
gallery.classList.remove(injectedElements.classes.galleryVisible);
mainImage.classList.remove(injectedElements.classes.imageLoaded);
mainImage.removeAttribute("src");
viewerTitle.removeAttribute("href");
viewerTitle.textContent = "";
viewerTitle.title = "";
viewerTitle.style.display = "none";
galleryItems = [];
currentGalleryIndex = -1;
sidebarList.replaceChildren();
document.body.style.overflow = bodyOverflowBeforeGallery;
document.removeEventListener("keydown", handleGalleryKeydown);
}
// ---------------------------------------------------------------------------
// 05.6 Gallery interaction handlers
// ---------------------------------------------------------------------------
function handleGalleryKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
closeGallery();
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
showPreviousImage();
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
showNextImage();
return;
}
if (event.key === "Home") {
event.preventDefault();
showGalleryItem(0);
return;
}
if (event.key === "End") {
event.preventDefault();
showGalleryItem(galleryItems.length - 1);
}
}
function handlePanelClick(event) {
const link = event.currentTarget;
const url = getLinkUrl(link);
event.stopImmediatePropagation();
event.stopPropagation();
if (!isValidImageUrl(url)) {
event.preventDefault();
return;
}
if (shouldUseNativeLinkBehavior(event)) {
return;
}
event.preventDefault();
openGallery(url);
}
// ===========================================================================
// 06. Event Registration and Startup
// ===========================================================================
// ---------------------------------------------------------------------------
// 06.1 Gallery event listeners
// ---------------------------------------------------------------------------
previousButton.addEventListener(
"click",
event => {
event.stopPropagation();
showPreviousImage();
}
);
nextButton.addEventListener(
"click",
event => {
event.stopPropagation();
showNextImage();
}
);
closeButton.addEventListener(
"click",
event => {
event.stopPropagation();
closeGallery();
}
);
mainImage.addEventListener(
"load",
() => {
loadingMessage.style.display = "none";
errorMessage.style.display = "none";
mainImage.classList.add(injectedElements.classes.imageLoaded);
}
);
mainImage.addEventListener(
"error",
() => {
loadingMessage.style.display = "none";
errorMessage.style.display = "block";
mainImage.classList.remove(injectedElements.classes.imageLoaded);
}
);
// ---------------------------------------------------------------------------
// 06.2 Script startup
// ---------------------------------------------------------------------------
interceptSearchResponses();
if (document.readyState === "loading") {
addEventListener("DOMContentLoaded", initialize, {once: true });
} else {
initialize();
}
})();