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