Civitai Larger Thumbnails + Arrow Key Navigation

Enlarges thumbnails and adds Up/Down arrow key navigation to instantly go to the next thumbnail. No more tired eyes from constantly moving your eyes from thumbnail to thumbnail.

// ==UserScript==
// @name        Civitai Larger Thumbnails + Arrow Key Navigation
// @namespace   Violentmonkey Scripts
// @match       *://*.civitai.com/*
// @grant       GM_addStyle
// @version     1.1
// @author      rainlizard
// @license     MIT
// @description Enlarges thumbnails and adds Up/Down arrow key navigation to instantly go to the next thumbnail. No more tired eyes from constantly moving your eyes from thumbnail to thumbnail.
// ==/UserScript==

(function() {
    'use strict';

    let currentItemIndex = 0;
    let items = [];
    let gridContainer = null;
    let isInitialized = false;
    let headerObserver = null;

    // Function to wait for elements to appear
    function waitForElement(selector, timeout = 10000) {
        return new Promise((resolve, reject) => {
            const element = document.querySelector(selector);
            if (element) {
                resolve(element);
                return;
            }

            const observer = new MutationObserver((mutations, obs) => {
                const element = document.querySelector(selector);
                if (element) {
                    obs.disconnect();
                    resolve(element);
                }
            });

            observer.observe(document, {
                childList: true,
                subtree: true
            });

            setTimeout(() => {
                observer.disconnect();
                reject(new Error(`Element ${selector} not found within ${timeout}ms`));
            }, timeout);
        });
    }

    // Function to calculate optimal thumbnail height
    function calculateThumbnailHeight() {
        // Get the actual viewport height
        const viewportHeight = window.innerHeight;

        // Check if header is visible by looking for common header selectors
        const headerSelectors = [
            'header',
            '[role="banner"]',
            '.header',
            '.navbar',
            '.nav-bar',
            '.top-bar',
            'nav:first-of-type'
        ];

        let headerHeight = 0;
        for (const selector of headerSelectors) {
            const header = document.querySelector(selector);
            if (header) {
                const rect = header.getBoundingClientRect();
                // Only count the header if it's visible (not hidden/transformed off-screen)
                if (rect.top >= -10 && rect.height > 0) {
                    headerHeight = Math.max(headerHeight, rect.height);
                }
            }
        }

        // Calculate available height (use full space)
        const availableHeight = viewportHeight - headerHeight;

        // Use 100% of available height (100vh)
        return availableHeight;
    }

    // Function to update thumbnail heights
    function updateThumbnailHeights() {
        const newHeight = calculateThumbnailHeight();
        const heightValue = `${newHeight}px`;

        // Update CSS custom property
        document.documentElement.style.setProperty('--civitai-thumbnail-height', heightValue);

        // Also update existing cards directly
        items.forEach(item => {
            if (item.classList.contains('civitai-fullscreen-card')) {
                item.style.height = heightValue;
                item.style.minHeight = heightValue;
            }
        });
    }

    // Function to find the grid container dynamically
    function findGridContainer() {
        // Look for common grid patterns
        const possibleSelectors = [
            '[class*="grid"]',
            '[class*="Grid"]',
            '[style*="grid"]',
            '[style*="display: grid"]',
            '[style*="display:grid"]',
            'div[class*="container"] > div[class*="grid"]',
            '#main [class*="grid"]',
            '.flex [class*="grid"]'
        ];

        for (const selector of possibleSelectors) {
            const elements = document.querySelectorAll(selector);
            for (const element of elements) {
                // Check if this element contains what looks like model cards
                const children = element.children;
                if (children.length > 2) { // Must have multiple items
                    // Look for image elements within children
                    let hasImages = 0;
                    for (let i = 0; i < Math.min(children.length, 5); i++) {
                        if (children[i].querySelector('img')) {
                            hasImages++;
                        }
                    }
                    if (hasImages >= 2) { // At least 2 children with images
                        return element;
                    }
                }
            }
        }
        return null;
    }

    // Function to find all card items within the grid
    function findCardItems(container) {
        if (!container) return [];

        const children = Array.from(container.children);
        return children.filter(child => {
            // Check if this looks like a card container
            const hasCardClasses = child.classList.contains('relative') || 
                                 child.querySelector('.AspectRatioImageCard_content__IGj_A');
            
            // Check if it has a valid structure (either loaded content or loading placeholder)
            const hasContent = child.querySelector('.AspectRatioImageCard_content__IGj_A');
            
            // Include if it has the card structure, regardless of whether content is loaded
            return hasCardClasses && hasContent;
        });
    }

    function updateItemList() {
        if (!gridContainer) {
            gridContainer = findGridContainer();
        }

        if (gridContainer) {
            items = findCardItems(gridContainer);
            if (currentItemIndex >= items.length && items.length > 0) {
                currentItemIndex = items.length - 1;
            } else if (items.length === 0) {
                currentItemIndex = 0;
            }
        }
    }

    function scrollToItem(index) {
        updateItemList();
        if (index >= 0 && index < items.length) {
            currentItemIndex = index;
            const targetElement = items[currentItemIndex];
            if (targetElement) {
                targetElement.scrollIntoView({
                    behavior: 'instant',
                    block: 'center',
                    inline: 'nearest'
                });
            }
        }
    }

    function handleKeyDown(event) {
        updateItemList();
        if (items.length === 0) return;

        if (event.key === 'ArrowDown') {
            event.preventDefault();
            if (currentItemIndex < items.length - 1) {
                scrollToItem(currentItemIndex + 1);
            } else {
                scrollToItem(items.length - 1);
            }
        } else if (event.key === 'ArrowUp') {
            event.preventDefault();
            if (currentItemIndex > 0) {
                scrollToItem(currentItemIndex - 1);
            } else {
                scrollToItem(0);
            }
        }
    }

    function applyStyles() {
        // Calculate initial height accounting for headers
        const initialHeight = calculateThumbnailHeight();
        document.documentElement.style.setProperty('--civitai-thumbnail-height', `${initialHeight}px`);

        GM_addStyle(`
            html, body {
                scroll-behavior: auto !important;
            }

            body {
                overflow-x: hidden !important;
            }

            /* Dynamic grid container styling - will be applied to detected grid */
            .civitai-fullscreen-grid {
                display: flex !important;
                flex-direction: column !important;
                align-items: center !important;
                width: 100% !important;
                gap: 0px !important;
                grid-template-columns: none !important;
            }

            /* Dynamic card styling - will be applied to detected cards */
            .civitai-fullscreen-card {
                width: 100vw !important;
                max-width: 100% !important;
                height: var(--civitai-thumbnail-height) !important;
                min-height: var(--civitai-thumbnail-height) !important;
                display: flex !important;
                flex-direction: column !important;
                justify-content: flex-start !important;
                align-items: stretch !important;
                padding: 0px !important;
                box-sizing: border-box !important;
                background-color: #1A1B1E !important;
                position: relative !important;
                margin: 0 !important;
                aspect-ratio: auto !important;
                transition: height 0.3s ease !important;
            }

            /* Style images within cards */
            .civitai-fullscreen-card img {
                width: 100% !important;
                height: 100% !important;
                object-fit: contain !important;
                display: block !important;
                border-radius: 4px !important;
            }

            /* Style links within cards */
            .civitai-fullscreen-card a {
                display: flex !important;
                flex-grow: 1 !important;
                height: 100% !important;
                justify-content: center !important;
                align-items: center !important;
            }
        `);
    }

    // Function to observe header visibility changes
    function observeHeaderChanges() {
        // Create a ResizeObserver to detect viewport changes
        const resizeObserver = new ResizeObserver(() => {
            updateThumbnailHeights();
        });
        resizeObserver.observe(document.body);

        // Also listen for scroll events to detect header hide/show
        let scrollTimeout;
        window.addEventListener('scroll', () => {
            clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(() => {
                updateThumbnailHeights();
            }, 100);
        });

        // Listen for window resize
        window.addEventListener('resize', () => {
            updateThumbnailHeights();
        });
    }

    function initializeFullscreen() {
        if (isInitialized) return;

        updateItemList();

        if (gridContainer && items.length > 0) {
            // Apply classes to the grid container
            gridContainer.classList.add('civitai-fullscreen-grid');

            // Apply classes to all card items
            items.forEach(item => {
                item.classList.add('civitai-fullscreen-card');
            });

            // Set up header observation
            observeHeaderChanges();

            isInitialized = true;
            console.log(`Civitai script initialized with ${items.length} items`);
        }
    }

    // Function to periodically check for new content
    function checkForUpdates() {
        const newGridContainer = findGridContainer();
        if (newGridContainer && newGridContainer !== gridContainer) {
            gridContainer = newGridContainer;
            isInitialized = false;
            initializeFullscreen();
        } else if (gridContainer) {
            const newItems = findCardItems(gridContainer);
            if (newItems.length !== items.length) {
                // New items detected, reapply styles
                newItems.forEach(item => {
                    if (!item.classList.contains('civitai-fullscreen-card')) {
                        item.classList.add('civitai-fullscreen-card');
                    }
                });
                items = newItems;
                // Update heights for new items
                updateThumbnailHeights();
            }
        }
    }

    // Initialize the script
    function init() {
        applyStyles();

        // Wait for the main content area to load
        waitForElement('#main')
            .then(() => {
                // Wait a bit more for dynamic content
                setTimeout(() => {
                    initializeFullscreen();

                    // Set up periodic checks for new content
                    setInterval(checkForUpdates, 2000);

                    // Add keyboard event listener
                    document.addEventListener('keydown', handleKeyDown);
                }, 2000);
            })
            .catch(error => {
                console.error('Civitai script: Failed to find main content area', error);
            });
    }

    // Start initialization after a delay to ensure page is ready
    setTimeout(init, 1000);

})();