Danbooru Hover Preview

image preview for Danbooru

// ==UserScript==
// @name         Danbooru Hover Preview
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  image preview for Danbooru
// @author       Claude 3.5 Sonnet & GPT-4o
// @match        https://danbooru.donmai.us/posts*
// @match        https://danbooru.donmai.us/
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    // Single preview element
    const preview = {
        container: null,
        img: null,
        loading: null,
        currentId: null,
        visible: false,
        targetX: 0,
        targetY: 0,
        currentX: 0,
        currentY: 0,
        animationFrame: null,
        opacity: 0,
        lastEvent: null,
        resizeObserver: null,
        initialPositionSet: false,

        init() {
            this.container = document.createElement('div');
            this.container.style.cssText = `
                position: fixed;
                z-index: 10000;
                background: white;
                box-shadow: 0 0 10px rgba(0,0,0,0.5);
                opacity: 0;
                max-width: 800px;
                max-height: 800px;
                pointer-events: none;
                transform: translate3d(0,0,0);
                will-change: transform, opacity;
                transition: opacity 0.2s ease;
                display: none;
            `;

            this.img = new Image();
            this.img.style.cssText = 'max-width: 800px; max-height: 800px;';

            this.loading = document.createElement('div');
            this.loading.textContent = 'Loading...';
            this.loading.style.cssText = 'padding: 20px; text-align: center;';

            // 创建 ResizeObserver 来监听尺寸变化
            this.resizeObserver = new ResizeObserver(entries => {
                if (this.lastEvent && !this.initialPositionSet) {
                    const pos = this.calculatePosition(this.lastEvent);
                    this.currentX = this.targetX = pos.left;
                    this.currentY = this.targetY = pos.top;
                    this.container.style.transform = `translate3d(${pos.left}px,${pos.top}px,0)`;
                    this.initialPositionSet = true;
                }
            });

            this.resizeObserver.observe(this.container);
            document.body.appendChild(this.container);
            this.startAnimation();
        },

        startAnimation() {
            const animate = () => {
                if (this.visible) {
                    this.currentX += (this.targetX - this.currentX) * 0.3;
                    this.currentY += (this.targetY - this.currentY) * 0.3;

                    this.container.style.transform =
                        `translate3d(${this.currentX}px,${this.currentY}px,0)`;
                }
                this.animationFrame = requestAnimationFrame(animate);
            };
            animate();
        },

        show(e) {
            this.lastEvent = e;
            this.initialPositionSet = false;
            this.container.style.display = 'block';

            // 初始位置设置
            requestAnimationFrame(() => {
                const pos = this.calculatePosition(e);
                this.currentX = this.targetX = pos.left;
                this.currentY = this.targetY = pos.top;
                this.container.style.transform = `translate3d(${pos.left}px,${pos.top}px,0)`;
                this.container.style.opacity = '1';
                this.visible = true;
            });
        },

        hide() {
            this.container.style.opacity = '0';
            this.visible = false;
            this.currentId = null;
            this.lastEvent = null;
            this.initialPositionSet = false;
            setTimeout(() => {
                if (!this.visible) {
                    this.container.style.display = 'none';
                }
            }, 200);
        },

        setLoading() {
            this.container.innerHTML = '';
            this.container.appendChild(this.loading);
        },

        setImage(img) {
            this.container.innerHTML = '';
            this.container.appendChild(img);
            this.initialPositionSet = false;  // 重置位置标志
        },

        calculatePosition(e) {
            const rect = this.container.getBoundingClientRect();
            const viewportWidth = window.innerWidth;
            const viewportHeight = window.innerHeight;

            // 根据可用空间自动判断显示位置
            let left;
            const spaceRight = viewportWidth - e.clientX - 20;
            const spaceLeft = e.clientX - rect.width - 20;

            // 优先选择右侧,如果右侧空间不够则选择左侧
            if (spaceRight >= rect.width) {
                left = e.clientX + 20;
            } else if (spaceLeft >= 0) {
                left = spaceLeft;
            } else {
                // 如果两侧都没有足够空间,选择空间较大的一侧
                left = (spaceRight > e.clientX) ?
                    viewportWidth - rect.width - 10 :
                    10;
            }

            // 确保顶部位置在视窗内
            let top = e.clientY;
            if (top + rect.height > viewportHeight) {
                top = Math.max(10, viewportHeight - rect.height - 10);
            }

            return { left, top };
        },

        updatePosition(e) {
            if (!this.visible) return;
            const pos = this.calculatePosition(e);
            this.targetX = pos.left;
            this.targetY = pos.top;
            this.lastEvent = e;
        }
    };

    // Request manager
    const requestManager = {
        queue: [],
        active: false,
        currentId: null,

        async process() {
            if (this.active || this.queue.length === 0) return;

            this.active = true;
            const task = this.queue.shift();

            try {
                await task();
            } catch (error) {
                console.error('Request error:', error);
            }

            this.active = false;
            this.process();
        },

        add(task) {
            this.queue.push(task);
            this.process();
        },

        clear() {
            this.queue.length = 0;
            this.active = false;
        }
    };

    // Cache manager
    const cache = {
        urls: new Map(),
        images: new Map(),
        loadFromStorage() {
            try {
                const stored = GM_getValue('urlCache', '{}');
                const data = JSON.parse(stored);
                const now = Date.now();
                const DAY = 24 * 60 * 60 * 1000;

                for (const [key, entry] of Object.entries(data)) {
                    if (now - entry.timestamp < DAY) {
                        this.urls.set(key, entry.url);
                    }
                }
            } catch (error) {
                console.error('Cache load error:', error);
            }
        },
        saveToStorage: debounce(() => {
            const data = {};
            cache.urls.forEach((url, key) => {
                data[key] = { url, timestamp: Date.now() };
            });
            GM_setValue('urlCache', JSON.stringify(data));
        }, 5000)
    };

    // Utilities
    function debounce(func, wait) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    // Image loader
    async function loadImage(imgId, baseUrl) {
        if (cache.urls.has(imgId)) {
            return cache.urls.get(imgId);
        }

        for (const ext of ['jpg', 'jpeg', 'png']) {
            const url = `${baseUrl}.${ext}`;
            try {
                const response = await fetch(url, { method: 'HEAD' });
                if (response.ok) {
                    cache.urls.set(imgId, url);
                    cache.saveToStorage();
                    return url;
                }
            } catch (error) {
                continue;
            }
        }
        return null;
    }

    // Preview handler
    function handlePreview(e, thumbnail) {
        const img = thumbnail.querySelector('img');
        if (!img) return;

        const match = img.src.match(/\/(\w+)\.\w+$/);
        if (!match) return;

        const imgId = match[1];
        if (preview.currentId === imgId) {
            preview.updatePosition(e);
            return;
        }
        preview.currentId = imgId;

        preview.setLoading();
        preview.show(e);

        if (cache.images.has(imgId)) {
            preview.setImage(cache.images.get(imgId).cloneNode(true));
            return;
        }

        requestManager.clear();
        requestManager.add(async () => {
            const folder1 = imgId.substring(0, 2);
            const folder2 = imgId.substring(2, 4);
            const baseUrl = `https://cdn.donmai.us/original/${folder1}/${folder2}/${imgId}`;

            try {
                const url = await loadImage(imgId, baseUrl);
                if (!url || preview.currentId !== imgId) return;

                const img = new Image();
                img.style.cssText = 'max-width: 800px; max-height: 800px;';

                img.onload = () => {
                    if (preview.currentId === imgId) {
                        cache.images.set(imgId, img.cloneNode(true));
                        preview.setImage(img);
                    }
                };

                img.src = url;
            } catch (error) {
                console.error('Image load error:', error);
            }
        });
    }

    // Event handler setup
    function setupHandlers(thumbnail) {
        if (thumbnail.dataset.previewInitialized) return;
        thumbnail.dataset.previewInitialized = 'true';

        thumbnail.addEventListener('mouseenter', e => handlePreview(e, thumbnail));
        thumbnail.addEventListener('mouseleave', () => preview.hide());
        thumbnail.addEventListener('mousemove', e => preview.updatePosition(e));
    }

    // Initialize
    function init() {
        preview.init();
        cache.loadFromStorage();

        document.querySelectorAll('.post-preview-link').forEach(setupHandlers);

        new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.classList?.contains('post-preview-link')) {
                        setupHandlers(node);
                    }
                });
            });
        }).observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // Start the script
    init();
})();