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