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.

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

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.0
// @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";

    const siteConfig = {
        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])",
            panel: ".panel_gidlp"
        },

        attributes: {
            jsdata: "jsdata",
            originalWidth: "ow",
            originalHeight: "oh",
            availability: "availability",
            thumbnailUrl: "thumbnailUrl"
        },

        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",
            thumbnailImage: "gallery_thumbnail_image_gidlp",
            thumbnailInfo: "gallery_thumbnail_info_gidlp",
            thumbnailIndex: "gallery_thumbnail_index_gidlp",
            thumbnailDimension: "gallery_thumbnail_dimension_gidlp",

            viewer: "gallery_viewer_gidlp",
            imageStage: "gallery_image_stage_gidlp",
            mainImage: "gallery_main_image_gidlp",
            imageLoaded: "gallery_image_loaded_gidlp",
            imageZoomed: "gallery_image_zoomed_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"
        },

        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"
        },

        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(siteConfig.selectors.jsdataHost)
                    ?.getAttribute(siteConfig.attributes.jsdata);

                const key = jsdata?.match(/[^;]+$/)?.[0];

                return key
                    ? cache?.[key]?.[1]?.[3]
                    : null;
            },

            controller(wrapper) {
                return wrapper
                    .closest(siteConfig.selectors.jsController)
                    ?.__jscontroller
                    ?.pending
                    ?.value
                    ?.Vz
                    ?.Bp
                    ?.[1]
                    ?.Bp
                    ?.[3];
            }
        }
    };

    const styles = `
${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.panel} {
    position: absolute;
    right: 0;
    bottom: 0;
    z-index: 1;
    font-size: 10pt;
    line-height: normal;
    opacity: 1;
    transition:
        opacity 0.2s ease,
        filter 0.2s ease;
}

${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.panel}::before {
    display: block;
    padding: 0 0.3ex;
    border-radius: 0.2em;
    background-color: #444;
    color: #fff;
    content:
        attr(data-${siteConfig.attributes.originalWidth})
        "x"
        attr(data-${siteConfig.attributes.originalHeight});
}

${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.panel}:visited::before {
    color: #f9f;
}

${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.waiting} {
    opacity: 1;
}

${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.checking} {
    opacity: 0.72;
}

${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.available} {
    opacity: 1;
}

${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.unavailable} {
    opacity: 0.42;
    filter: grayscale(1);
}

${siteConfig.selectors.resultContainer}
${siteConfig.selectors.imageWrapper} > .${siteConfig.classes.unavailable}::before {
    text-decoration: line-through;
}

.${siteConfig.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;
}

.${siteConfig.classes.gallery}.${siteConfig.classes.galleryVisible} {
    visibility: visible;
    opacity: 1;
    pointer-events: auto;
}

.${siteConfig.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);
}

.${siteConfig.classes.sidebarHeader} {
    flex: 0 0 auto;
    padding: 18px 16px;
    color: #fff;
    border-bottom: 1px solid rgba(255, 255, 255, 0.13);
    font: 600 18px/1.3 Arial, sans-serif;
}

.${siteConfig.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;
    scrollbar-width: thin;
    scrollbar-color:
        rgba(255, 255, 255, 0.45)
        rgba(255, 255, 255, 0.08);
}

.${siteConfig.classes.sidebarList}::-webkit-scrollbar {
    width: 10px;
}

.${siteConfig.classes.sidebarList}::-webkit-scrollbar-track {
    background-color: rgba(255, 255, 255, 0.07);
}

.${siteConfig.classes.sidebarList}::-webkit-scrollbar-thumb {
    border: 2px solid transparent;
    border-radius: 999px;
    background-color: rgba(255, 255, 255, 0.42);
    background-clip: padding-box;
}

.${siteConfig.classes.thumbnailItem} {
    position: relative;
    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);
    cursor: pointer;
    text-align: left;
    transition:
        border-color 0.15s ease,
        background-color 0.15s ease,
        opacity 0.15s ease,
        transform 0.15s ease;
}

.${siteConfig.classes.thumbnailItem}:hover {
    background-color: rgba(255, 255, 255, 0.14);
    transform: scale(1.025);
}

.${siteConfig.classes.thumbnailItem}.${siteConfig.classes.thumbnailActive} {
    border-color: #8ab4f8;
    box-shadow:
        0 0 0 1px rgba(138, 180, 248, 0.4),
        0 3px 14px rgba(0, 0, 0, 0.35);
}

.${siteConfig.classes.thumbnailItem}.${siteConfig.classes.thumbnailUnavailable} {
    opacity: 0.38;
    filter: grayscale(1);
}

.${siteConfig.classes.thumbnailImage} {
    display: block;
    width: 100%;
    height: 100%;
    min-height: 90px;
    max-height: 260px;
    border-radius: 10px;
    object-fit: contain;
    background-color: rgba(255, 255, 255, 0.08);
}

.${siteConfig.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;
}

.${siteConfig.classes.thumbnailIndex},
.${siteConfig.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 10px/1.2 Arial, sans-serif;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.${siteConfig.classes.thumbnailIndex} {
    flex: 0 0 auto;
}

.${siteConfig.classes.thumbnailDimension} {
    flex: 0 1 auto;
}

.${siteConfig.classes.thumbnailItem}.${siteConfig.classes.thumbnailUnavailable}
.${siteConfig.classes.thumbnailIndex},
.${siteConfig.classes.thumbnailItem}.${siteConfig.classes.thumbnailUnavailable}
.${siteConfig.classes.thumbnailDimension} {
    opacity: 0.55;
    text-decoration: line-through;
}

.${siteConfig.classes.viewer} {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: 0;
    height: 100vh;
    overflow: hidden;
    background-color: rgba(0, 0, 0, 0.58);
    backdrop-filter: blur(2px);
    -webkit-backdrop-filter: blur(2px);
}

.${siteConfig.classes.imageStage} {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    width: calc(100% - 150px);
    height: calc(100% - 72px);
    overflow: auto;
}

.${siteConfig.classes.mainImage} {
    display: block;
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    opacity: 0;
    cursor: zoom-in;
    user-select: none;
    filter: drop-shadow(0 12px 40px rgba(0, 0, 0, 0.55));
    transition: opacity 0.2s ease;
}

.${siteConfig.classes.mainImage}.${siteConfig.classes.imageLoaded} {
    opacity: 1;
}

.${siteConfig.classes.mainImage}.${siteConfig.classes.imageZoomed} {
    max-width: none;
    max-height: none;
    cursor: zoom-out;
}

.${siteConfig.classes.loading},
.${siteConfig.classes.error} {
    position: absolute;
    max-width: 320px;
    padding: 12px 16px;
    border-radius: 8px;
    color: #fff;
    background-color: rgba(0, 0, 0, 0.7);
    font: 14px/1.4 Arial, sans-serif;
    text-align: center;
    pointer-events: none;
}

.${siteConfig.classes.error} {
    display: none;
}

.${siteConfig.classes.navigationButton},
.${siteConfig.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;
}

.${siteConfig.classes.navigationButton}:hover,
.${siteConfig.classes.closeButton}:hover {
    background-color: rgba(0, 0, 0, 0.78);
}

.${siteConfig.classes.navigationButton} {
    top: 50%;
    width: 48px;
    height: 72px;
    border-radius: 12px;
    font: 34px/1 Arial, sans-serif;
    transform: translateY(-50%);
}

.${siteConfig.classes.navigationButton}:active {
    transform: translateY(-50%) scale(0.94);
}

.${siteConfig.classes.previousButton} {
    left: 16px;
}

.${siteConfig.classes.nextButton} {
    right: 16px;
}

.${siteConfig.classes.closeButton} {
    top: 16px;
    right: 16px;
    width: 42px;
    height: 42px;
    border-radius: 50%;
    font: 27px/1 Arial, sans-serif;
}

.${siteConfig.classes.closeButton}:active {
    transform: scale(0.94);
}

.${siteConfig.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: 12px/1.2 Arial, sans-serif;
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    transform: translateX(-50%);
}

@media (max-width: 900px) {
    .${siteConfig.classes.gallery} {
        grid-template-columns: 210px minmax(0, 1fr);
    }
}

@media (max-width: 620px) {
    .${siteConfig.classes.gallery} {
        grid-template-columns: 110px minmax(0, 1fr);
    }

    .${siteConfig.classes.sidebarHeader} {
        padding: 14px 10px;
        font-size: 14px;
    }

    .${siteConfig.classes.sidebarList} {
        gap: 8px;
        padding: 7px;
    }

    .${siteConfig.classes.thumbnailInfo} {
        right: 3px;
        bottom: 3px;
        padding: 2px 4px;
        font-size: 9px;
    }

    .${siteConfig.classes.imageStage} {
        width: calc(100% - 92px);
    }

    .${siteConfig.classes.navigationButton} {
        width: 38px;
        height: 58px;
    }

    .${siteConfig.classes.previousButton} {
        left: 7px;
    }

    .${siteConfig.classes.nextButton} {
        right: 7px;
    }
}
`;

    const availabilityCache = new Map();
    const pendingUrls = new Set();
    const checkQueue = [];

    let resultContainer = null;
    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
    );

    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 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 = siteConfig.classes.gallery;
    gallerySidebar.className = siteConfig.classes.sidebar;
    sidebarHeader.className = siteConfig.classes.sidebarHeader;
    sidebarList.className = siteConfig.classes.sidebarList;
    galleryViewer.className = siteConfig.classes.viewer;
    imageStage.className = siteConfig.classes.imageStage;
    mainImage.className = siteConfig.classes.mainImage;
    loadingMessage.className = siteConfig.classes.loading;
    errorMessage.className = siteConfig.classes.error;

    previousButton.className =
        `${siteConfig.classes.navigationButton} ` +
        siteConfig.classes.previousButton;

    nextButton.className =
        `${siteConfig.classes.navigationButton} ` +
        siteConfig.classes.nextButton;

    closeButton.className = siteConfig.classes.closeButton;
    counter.className = siteConfig.classes.counter;

    sidebarHeader.textContent = siteConfig.messages.galleryTitle;
    loadingMessage.textContent = siteConfig.messages.loading;
    errorMessage.textContent = siteConfig.messages.error;

    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(
        imageStage,
        previousButton,
        nextButton,
        closeButton,
        counter
    );

    gallery.append(
        gallerySidebar,
        galleryViewer
    );

    function mountGallery() {
        if (document.body && !gallery.isConnected) {
            document.body.append(gallery);
        }
    }

    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 shouldUseNativeLinkBehavior(event) {
        return (
            event.button !== 0 ||
            event.ctrlKey ||
            event.metaKey ||
            event.shiftKey ||
            event.altKey
        );
    }

    function getThumbnailUrl(wrapper) {
        if (!wrapper) {
            return "";
        }

        const images = Array.from(
            wrapper.querySelectorAll(
                siteConfig.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(
            siteConfig.selectors.imageWrapper
        );

        const freshThumbnailUrl =
            getThumbnailUrl(wrapper);

        if (freshThumbnailUrl) {
            link.dataset[
                siteConfig.attributes.thumbnailUrl
            ] = freshThumbnailUrl;
        }

        return (
            freshThumbnailUrl ||
            link.dataset[
                siteConfig.attributes.thumbnailUrl
            ] ||
            ""
        );
    }

    function setAvailability(link, state) {
        link.classList.remove(
            siteConfig.classes.waiting,
            siteConfig.classes.checking,
            siteConfig.classes.available,
            siteConfig.classes.unavailable
        );

        link.classList.add(siteConfig.classes[state]);

        link.dataset[
            siteConfig.attributes.availability
        ] = state;

        link.title = siteConfig.messages[state];
    }

    function applyAvailabilityToMatchingLinks(
        url,
        available
    ) {
        if (!resultContainer) {
            return;
        }

        resultContainer
            .querySelectorAll(siteConfig.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(
                `.${siteConfig.classes.thumbnailItem}`
            )
            .forEach((thumbnail, index) => {
                const item = galleryItems[index];

                if (!item || item.url !== url) {
                    return;
                }

                thumbnail.classList.toggle(
                    siteConfig.classes.thumbnailUnavailable,
                    !available
                );
            });
    }

    function finishAvailabilityCheck(url, available) {
        availabilityCache.set(url, available);
        pendingUrls.delete(url);

        applyAvailabilityToMatchingLinks(
            url,
            available
        );

        activeChecks = Math.max(
            0,
            activeChecks - 1
        );

        runCheckQueue();
    }

    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")
        );
    }

    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);
    }

    function collectGalleryItems() {
        if (!resultContainer) {
            return [];
        }

        const seenUrls = new Set();
        const items = [];

        resultContainer
            .querySelectorAll(siteConfig.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[
                            siteConfig.attributes.originalWidth
                        ] || "???",
                    height:
                        link.dataset[
                            siteConfig.attributes.originalHeight
                        ] || "???",
                    available:
                        availabilityCache.get(url),
                    link
                });
            });

        return items;
    }

    function createThumbnailItem(item, index) {
        const button = document.createElement("button");
        const image = document.createElement("img");
        const info = document.createElement("span");
        const indexBadge = document.createElement("span");
        const dimensionBadge = document.createElement("span");

        button.type = "button";
        button.className =
            siteConfig.classes.thumbnailItem;

        button.title =
            `${index + 1}. ${item.width} × ${item.height}`;

        image.className =
            siteConfig.classes.thumbnailImage;

        info.className =
            siteConfig.classes.thumbnailInfo;

        indexBadge.className =
            siteConfig.classes.thumbnailIndex;

        dimensionBadge.className =
            siteConfig.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
        ) {
            button.style.aspectRatio =
                `${numericWidth} / ${numericHeight}`;
        }

        indexBadge.textContent =
            String(index + 1);

        dimensionBadge.textContent =
            `${item.width} × ${item.height}`;

        info.append(
            indexBadge,
            dimensionBadge
        );

        if (index === currentGalleryIndex) {
            button.classList.add(
                siteConfig.classes.thumbnailActive
            );
        }

        if (item.available === false) {
            button.classList.add(
                siteConfig.classes.thumbnailUnavailable
            );
        }

        button.addEventListener("click", () => {
            showGalleryItem(index);
        });

        button.append(image, info);

        return button;
    }

    function renderGallerySidebar() {
        sidebarList.replaceChildren();

        galleryItems.forEach((item, index) => {
            sidebarList.append(
                createThumbnailItem(item, index)
            );
        });
    }

    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();
        updateGalleryCounter();
        scrollActiveThumbnailIntoView();
    }

    function updateGalleryCounter() {
        if (
            currentGalleryIndex < 0 ||
            !galleryItems.length
        ) {
            counter.textContent = "0 / 0";
            return;
        }

        counter.textContent =
            `${currentGalleryIndex + 1} / ` +
            `${galleryItems.length}`;
    }

    function updateActiveThumbnail() {
        sidebarList
            .querySelectorAll(
                `.${siteConfig.classes.thumbnailItem}`
            )
            .forEach((item, index) => {
                item.classList.toggle(
                    siteConfig.classes.thumbnailActive,
                    index === currentGalleryIndex
                );
            });

        scrollActiveThumbnailIntoView();
    }

    function scrollActiveThumbnailIntoView() {
        const activeThumbnail =
            sidebarList.querySelector(
                `.${siteConfig.classes.thumbnailActive}`
            );

        activeThumbnail?.scrollIntoView({
            block: "nearest",
            behavior: "smooth"
        });
    }

    function showGalleryItem(index) {
        if (!galleryItems.length) {
            return;
        }

        const normalizedIndex =
            (index + galleryItems.length) %
            galleryItems.length;

        const item = galleryItems[normalizedIndex];

        currentGalleryIndex = normalizedIndex;

        mainImage.classList.remove(
            siteConfig.classes.imageLoaded,
            siteConfig.classes.imageZoomed
        );

        loadingMessage.style.display = "";
        errorMessage.style.display = "none";

        mainImage.removeAttribute("src");

        requestAnimationFrame(() => {
            mainImage.src = item.url;
        });

        updateGalleryCounter();
        updateActiveThumbnail();
    }

    function showPreviousImage() {
        if (currentGalleryIndex <= 0) {
            return;
        }

        showGalleryItem(currentGalleryIndex - 1);
    }

    function showNextImage() {
        if (
            currentGalleryIndex >=
            galleryItems.length - 1
        ) {
            return;
        }

        showGalleryItem(currentGalleryIndex + 1);
    }

    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(
            siteConfig.classes.galleryVisible
        );

        showGalleryItem(currentGalleryIndex);

        document.addEventListener(
            "keydown",
            handleGalleryKeydown
        );
    }

    function closeGallery() {
        gallery.classList.remove(
            siteConfig.classes.galleryVisible
        );

        mainImage.classList.remove(
            siteConfig.classes.imageLoaded,
            siteConfig.classes.imageZoomed
        );

        mainImage.removeAttribute("src");

        galleryItems = [];
        currentGalleryIndex = -1;
        sidebarList.replaceChildren();

        document.body.style.overflow =
            bodyOverflowBeforeGallery;

        document.removeEventListener(
            "keydown",
            handleGalleryKeydown
        );
    }

    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);
    }

    function configureLink(
        link,
        imageData,
        wrapper
    ) {
        const imageUrl = imageData?.[0];
        const previousUrl = getLinkUrl(link);

        link.rel = "nofollow noopener noreferrer";

        link.dataset[
            siteConfig.attributes.originalWidth
        ] = imageData?.[2] || "???";

        link.dataset[
            siteConfig.attributes.originalHeight
        ] = imageData?.[1] || "???";

        link.dataset[
            siteConfig.attributes.thumbnailUrl
        ] = getThumbnailUrl(wrapper);

        if (isValidImageUrl(imageUrl)) {
            if (previousUrl !== imageUrl) {
                visibilityObserver.unobserve(link);

                link.classList.remove(
                    siteConfig.classes.waiting,
                    siteConfig.classes.checking,
                    siteConfig.classes.available,
                    siteConfig.classes.unavailable
                );

                delete link.dataset[
                    siteConfig.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(
            siteConfig.selectors.panel
        );

        if (link) {
            return link;
        }

        link = document.createElement("a");
        link.className = siteConfig.classes.panel;

        wrapper.insertBefore(
            link,
            wrapper.firstChild
        );

        return link;
    }

    function processCachedEntry(wrapper) {
        const imageData =
            siteConfig.dataReaders.cached(
                wrapper,
                imageDataCache
            );

        wrapper.classList.add(
            siteConfig.classes.processed
        );

        const link = createPanel(wrapper);

        configureLink(
            link,
            imageData,
            wrapper
        );
    }

    function waitForControllerData(wrapper) {
        if (!wrapper.isConnected) {
            return;
        }

        const imageData =
            siteConfig.dataReaders.controller(
                wrapper
            );

        if (imageData) {
            wrapper.classList.add(
                siteConfig.classes.processed
            );

            const link = createPanel(wrapper);

            configureLink(
                link,
                imageData,
                wrapper
            );

            return;
        }

        setTimeout(
            waitForControllerData,
            siteConfig.request.retryDelay,
            wrapper
        );
    }

    function processEntries() {
        if (!resultContainer) {
            return;
        }

        const selector =
            `${siteConfig.selectors.imageWrapper}` +
            `:not(.${siteConfig.classes.processed})`;

        resultContainer
            .querySelectorAll(selector)
            .forEach(wrapper => {
                if (imageDataCache) {
                    processCachedEntry(wrapper);
                } else {
                    waitForControllerData(wrapper);
                }
            });

        if (
            gallery.classList.contains(
                siteConfig.classes.galleryVisible
            )
        ) {
            refreshGalleryItems();
        }
    }

    function scheduleEntryProcessing() {
        clearTimeout(processTimer);

        processTimer = setTimeout(
            processEntries,
            siteConfig.request.processDelay
        );
    }

    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(
                siteConfig.selectors.inlineScript
            )
            .forEach(script => {
                if (imageDataCache) {
                    return;
                }

                imageDataCache =
                    siteConfig.dataReaders.initial(
                        script
                    );
            });
    }

    function observeResults() {
        resultContainer =
            document.querySelector(
                siteConfig.selectors.resultContainer
            );

        if (!resultContainer) {
            return;
        }

        const mutationObserver =
            new MutationObserver(
                scheduleEntryProcessing
            );

        mutationObserver.observe(
            resultContainer,
            {
                childList: true,
                subtree: true
            }
        );

        processEntries();
    }

    function initialize() {
        mountGallery();
        readInitialImageData();
        observeResults();
    }

    previousButton.addEventListener(
        "click",
        event => {
            event.stopPropagation();
            showPreviousImage();
        }
    );

    nextButton.addEventListener(
        "click",
        event => {
            event.stopPropagation();
            showNextImage();
        }
    );

    closeButton.addEventListener(
        "click",
        event => {
            event.stopPropagation();
            closeGallery();
        }
    );

    mainImage.addEventListener(
        "click",
        event => {
            event.stopPropagation();

            mainImage.classList.toggle(
                siteConfig.classes.imageZoomed
            );
        }
    );

    mainImage.addEventListener(
        "load",
        () => {
            loadingMessage.style.display = "none";
            errorMessage.style.display = "none";

            mainImage.classList.add(
                siteConfig.classes.imageLoaded
            );
        }
    );

    mainImage.addEventListener(
        "error",
        () => {
            loadingMessage.style.display = "none";
            errorMessage.style.display = "block";

            mainImage.classList.remove(
                siteConfig.classes.imageLoaded
            );
        }
    );

    interceptSearchResponses();

    if (document.readyState === "loading") {
        addEventListener(
            "DOMContentLoaded",
            initialize,
            { once: true }
        );
    } else {
        initialize();
    }
})();