Google Image Gallery

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Google Image Gallery
// @namespace    https://greasyfork.org/
// @version      1.4
// @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",
            nativeIndex: ".google_image_index_gidlp"
        },

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

        classes: {
            processed: "paneled_gidlp",
            panel: "panel_gidlp",
            nativeIndex: "google_image_index_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,
            thumbnailRetryDelay: 150,
            thumbnailRetryCount: 20,
            galleryOpenDelay: 120,
            galleryScrollDelay: 120,
            galleryScrollPadding: 2
        },

        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.nativeIndex} {
    position: absolute;
    left: 0;
    bottom: 0;
    z-index: 10;
    display: block;
    min-width: 18px;
    padding: 2px 6px;
    border-radius: 4px;
    color: #fff;
    background-color: rgba(0, 0, 0, 0.82);
    font: 600 12px/1.2 Arial, sans-serif;
    text-align: center;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
    pointer-events: none;
}

${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%;
    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: 0;
    max-height: none;
    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 = "";
    let pageScrollBeforeGallery = 0;
    let galleryScrollTimer = null;
    let lastLazyLoadIndex = -1;

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


    function updateNativeImageIndices() {
        const seenUrls = new Set();
        let imageIndex = 0;

        document.querySelectorAll(injectedElements.selectors.panel).forEach(link => {
            const wrapper = link.closest(pageElements.selectors.imageWrapper);

            if (!wrapper) {
                return;
            }

            let badge = wrapper.querySelector(injectedElements.selectors.nativeIndex);

            if (!badge) {
                badge = document.createElement("span");
                badge.className = injectedElements.classes.nativeIndex;
                wrapper.insertBefore(badge, wrapper.firstChild);
            }

            const url = getLinkUrl(link);

            if (!isValidImageUrl(url) || seenUrls.has(url)) {
                badge.textContent = "";
                badge.style.display = "none";
                return;
            }

            seenUrls.add(url);
            imageIndex++;
            badge.textContent = String(imageIndex);
            badge.style.display = "block";
        });
    }

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

        updateNativeImageIndices();

        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,
            attributes: true,
            attributeFilter: ["src", "srcset", "data-src", "data-iurl"]
        });

        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 requestNativeThumbnailLoad(wrapper) {
        if (!wrapper) {
            return;
        }

        wrapper.querySelectorAll(pageElements.selectors.thumbnail).forEach(image => {
            image.loading = "eager";
            image.decoding = "async";

            const lazyUrl =
                image.getAttribute("data-src") ||
                image.getAttribute("data-iurl") ||
                "";

            if (!image.getAttribute("src") && isValidImageUrl(lazyUrl)) {
                image.src = lazyUrl;
            }
        });
    }

    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}`;
        card.dataset.galleryUrl = item.url;
        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 = "eager";
        image.decoding = "async";
        image.draggable = false;

        function updateThumbnail() {
            const freshThumbnailUrl = getCurrentThumbnailUrl(item.link);

            if (freshThumbnailUrl && freshThumbnailUrl !== image.getAttribute("src")) {
                item.thumbnailUrl = freshThumbnailUrl;
                image.src = freshThumbnailUrl;
                return true;
            }

            return false;
        }

        if (item.thumbnailUrl) {
            image.src = item.thumbnailUrl;
        } else {
            requestNativeThumbnailLoad(item.link.closest(pageElements.selectors.imageWrapper));
        }

        image.addEventListener("error", updateThumbnail);

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

        refreshMissingGalleryThumbnails();
    }

    function refreshMissingGalleryThumbnails(attempt = 0) {
        if (
            !gallery.classList.contains(injectedElements.classes.galleryVisible) ||
            attempt >= siteConfig.request.thumbnailRetryCount
        ) {
            return;
        }

        let missingCount = 0;

        galleryItems.forEach((item, index) => {
            const card = sidebarList.children[index];
            const galleryImage = card?.querySelector(`.${injectedElements.classes.thumbnailImage}`);

            if (!galleryImage) {
                return;
            }

            const wrapper = item.link.closest(pageElements.selectors.imageWrapper);
            requestNativeThumbnailLoad(wrapper);

            const freshThumbnailUrl = getCurrentThumbnailUrl(item.link);

            if (freshThumbnailUrl) {
                item.thumbnailUrl = freshThumbnailUrl;

                if (galleryImage.getAttribute("src") !== freshThumbnailUrl) {
                    galleryImage.src = freshThumbnailUrl;
                }
            } else {
                missingCount++;
            }
        });

        if (missingCount > 0) {
            setTimeout(
                () => refreshMissingGalleryThumbnails(attempt + 1),
                siteConfig.request.thumbnailRetryDelay
            );
        }
    }

    function getVisibleGalleryThumbnailRange() {
        const listRect = sidebarList.getBoundingClientRect();
        const cards = Array.from(
            sidebarList.querySelectorAll(`.${injectedElements.classes.thumbnailItem}`)
        );
        let firstIndex = -1;
        let lastIndex = -1;

        cards.forEach((card, index) => {
            const rect = card.getBoundingClientRect();
            const visible = rect.bottom >= listRect.top && rect.top <= listRect.bottom;

            if (!visible) {
                return;
            }

            if (firstIndex < 0) {
                firstIndex = index;
            }

            lastIndex = index;
        });

        return {firstIndex, lastIndex};
    }

    function refreshGalleryThumbnailRange(firstIndex, lastIndex) {
        const startIndex = Math.max(0, firstIndex - siteConfig.request.galleryScrollPadding);
        const endIndex = Math.min(
            galleryItems.length - 1,
            lastIndex + siteConfig.request.galleryScrollPadding
        );

        for (let index = startIndex; index <= endIndex; index++) {
            const item = galleryItems[index];
            const card = sidebarList.children[index];
            const galleryImage = card?.querySelector(
                `.${injectedElements.classes.thumbnailImage}`
            );

            if (!item || !galleryImage) {
                continue;
            }

            const wrapper = item.link.closest(pageElements.selectors.imageWrapper);
            requestNativeThumbnailLoad(wrapper);

            const freshThumbnailUrl = getCurrentThumbnailUrl(item.link);

            if (freshThumbnailUrl) {
                item.thumbnailUrl = freshThumbnailUrl;

                if (galleryImage.getAttribute("src") !== freshThumbnailUrl) {
                    galleryImage.src = freshThumbnailUrl;
                }
            }
        }
    }

    function triggerGoogleLazyLoadFromGallery() {
        if (!gallery.classList.contains(injectedElements.classes.galleryVisible)) {
            return;
        }

        const {firstIndex, lastIndex} = getVisibleGalleryThumbnailRange();

        if (firstIndex < 0 || lastIndex < 0) {
            return;
        }

        const targetIndex = Math.round((firstIndex + lastIndex) / 2);
        const targetItem = galleryItems[targetIndex];
        const targetWrapper = targetItem?.link.closest(pageElements.selectors.imageWrapper);

        refreshGalleryThumbnailRange(firstIndex, lastIndex);

        if (!targetWrapper || targetIndex === lastLazyLoadIndex) {
            return;
        }

        lastLazyLoadIndex = targetIndex;

        const targetTop = Math.max(
            0,
            window.scrollY + targetWrapper.getBoundingClientRect().top - window.innerHeight * 0.35
        );

        window.scrollTo({top: targetTop, left: 0, behavior: "auto"});
        window.dispatchEvent(new Event("scroll"));

        requestAnimationFrame(() => {
            requestNativeThumbnailLoad(targetWrapper);
            refreshGalleryThumbnailRange(firstIndex, lastIndex);

            setTimeout(() => {
                refreshGalleryThumbnailRange(firstIndex, lastIndex);
                processEntries();
            }, siteConfig.request.thumbnailRetryDelay);
        });
    }

    function scheduleGoogleLazyLoadFromGallery() {
        clearTimeout(galleryScrollTimer);
        galleryScrollTimer = setTimeout(
            triggerGoogleLazyLoadFromGallery,
            siteConfig.request.galleryScrollDelay
        );
    }

    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();
        pageScrollBeforeGallery = window.scrollY;
        lastLazyLoadIndex = -1;

        document
            .querySelectorAll(pageElements.selectors.imageWrapper)
            .forEach(requestNativeThumbnailLoad);

        setTimeout(() => {
            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();
            refreshMissingGalleryThumbnails();
            scheduleGoogleLazyLoadFromGallery();

            document.addEventListener("keydown", handleGalleryKeydown);
        }, siteConfig.request.galleryOpenDelay);
    }

    function closeGallery() {
        gallery.classList.remove(injectedElements.classes.galleryVisible);
        clearTimeout(galleryScrollTimer);
        galleryScrollTimer = null;
        lastLazyLoadIndex = -1;

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

    sidebarList.addEventListener(
        "scroll",
        scheduleGoogleLazyLoadFromGallery,
        {passive: true}
    );

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