Google Image Gallery

Adds direct image links, and displays Google Images results in an in-page gallery.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
    }
})();