Sleazy Fork is available in English.

Danbooru Hover Preview

image preview for Danbooru

  1. // ==UserScript==
  2. // @name Danbooru Hover Preview
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.6
  5. // @description image preview for Danbooru
  6. // @author Claude 3.5 Sonnet & GPT-4o
  7. // @match https://danbooru.donmai.us/posts*
  8. // @match https://danbooru.donmai.us/
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16. // Single preview element
  17. const preview = {
  18. container: null,
  19. img: null,
  20. loading: null,
  21. currentId: null,
  22. visible: false,
  23. targetX: 0,
  24. targetY: 0,
  25. currentX: 0,
  26. currentY: 0,
  27. animationFrame: null,
  28. opacity: 0,
  29. lastEvent: null,
  30. resizeObserver: null,
  31. initialPositionSet: false,
  32.  
  33. init() {
  34. this.container = document.createElement('div');
  35. this.container.style.cssText = `
  36. position: fixed;
  37. z-index: 10000;
  38. background: white;
  39. box-shadow: 0 0 10px rgba(0,0,0,0.5);
  40. opacity: 0;
  41. max-width: 800px;
  42. max-height: 800px;
  43. pointer-events: none;
  44. transform: translate3d(0,0,0);
  45. will-change: transform, opacity;
  46. transition: opacity 0.2s ease;
  47. display: none;
  48. `;
  49.  
  50. this.img = new Image();
  51. this.img.style.cssText = 'max-width: 800px; max-height: 800px;';
  52.  
  53. this.loading = document.createElement('div');
  54. this.loading.textContent = 'Loading...';
  55. this.loading.style.cssText = 'padding: 20px; text-align: center;';
  56.  
  57. // 创建 ResizeObserver 来监听尺寸变化
  58. this.resizeObserver = new ResizeObserver(entries => {
  59. if (this.lastEvent && !this.initialPositionSet) {
  60. const pos = this.calculatePosition(this.lastEvent);
  61. this.currentX = this.targetX = pos.left;
  62. this.currentY = this.targetY = pos.top;
  63. this.container.style.transform = `translate3d(${pos.left}px,${pos.top}px,0)`;
  64. this.initialPositionSet = true;
  65. }
  66. });
  67.  
  68. this.resizeObserver.observe(this.container);
  69. document.body.appendChild(this.container);
  70. this.startAnimation();
  71. },
  72.  
  73. startAnimation() {
  74. const animate = () => {
  75. if (this.visible) {
  76. this.currentX += (this.targetX - this.currentX) * 0.3;
  77. this.currentY += (this.targetY - this.currentY) * 0.3;
  78.  
  79. this.container.style.transform =
  80. `translate3d(${this.currentX}px,${this.currentY}px,0)`;
  81. }
  82. this.animationFrame = requestAnimationFrame(animate);
  83. };
  84. animate();
  85. },
  86.  
  87. show(e) {
  88. this.lastEvent = e;
  89. this.initialPositionSet = false;
  90. this.container.style.display = 'block';
  91.  
  92. // 初始位置设置
  93. requestAnimationFrame(() => {
  94. const pos = this.calculatePosition(e);
  95. this.currentX = this.targetX = pos.left;
  96. this.currentY = this.targetY = pos.top;
  97. this.container.style.transform = `translate3d(${pos.left}px,${pos.top}px,0)`;
  98. this.container.style.opacity = '1';
  99. this.visible = true;
  100. });
  101. },
  102.  
  103. hide() {
  104. this.container.style.opacity = '0';
  105. this.visible = false;
  106. this.currentId = null;
  107. this.lastEvent = null;
  108. this.initialPositionSet = false;
  109. setTimeout(() => {
  110. if (!this.visible) {
  111. this.container.style.display = 'none';
  112. }
  113. }, 200);
  114. },
  115.  
  116. setLoading() {
  117. this.container.innerHTML = '';
  118. this.container.appendChild(this.loading);
  119. },
  120.  
  121. setImage(img) {
  122. this.container.innerHTML = '';
  123. this.container.appendChild(img);
  124. this.initialPositionSet = false; // 重置位置标志
  125. },
  126.  
  127. calculatePosition(e) {
  128. const rect = this.container.getBoundingClientRect();
  129. const viewportWidth = window.innerWidth;
  130. const viewportHeight = window.innerHeight;
  131.  
  132. // 根据可用空间自动判断显示位置
  133. let left;
  134. const spaceRight = viewportWidth - e.clientX - 20;
  135. const spaceLeft = e.clientX - rect.width - 20;
  136.  
  137. // 优先选择右侧,如果右侧空间不够则选择左侧
  138. if (spaceRight >= rect.width) {
  139. left = e.clientX + 20;
  140. } else if (spaceLeft >= 0) {
  141. left = spaceLeft;
  142. } else {
  143. // 如果两侧都没有足够空间,选择空间较大的一侧
  144. left = (spaceRight > e.clientX) ?
  145. viewportWidth - rect.width - 10 :
  146. 10;
  147. }
  148.  
  149. // 确保顶部位置在视窗内
  150. let top = e.clientY;
  151. if (top + rect.height > viewportHeight) {
  152. top = Math.max(10, viewportHeight - rect.height - 10);
  153. }
  154.  
  155. return { left, top };
  156. },
  157.  
  158. updatePosition(e) {
  159. if (!this.visible) return;
  160. const pos = this.calculatePosition(e);
  161. this.targetX = pos.left;
  162. this.targetY = pos.top;
  163. this.lastEvent = e;
  164. }
  165. };
  166.  
  167. // Request manager
  168. const requestManager = {
  169. queue: [],
  170. active: false,
  171. currentId: null,
  172.  
  173. async process() {
  174. if (this.active || this.queue.length === 0) return;
  175.  
  176. this.active = true;
  177. const task = this.queue.shift();
  178.  
  179. try {
  180. await task();
  181. } catch (error) {
  182. console.error('Request error:', error);
  183. }
  184.  
  185. this.active = false;
  186. this.process();
  187. },
  188.  
  189. add(task) {
  190. this.queue.push(task);
  191. this.process();
  192. },
  193.  
  194. clear() {
  195. this.queue.length = 0;
  196. this.active = false;
  197. }
  198. };
  199.  
  200. // Cache manager
  201. const cache = {
  202. urls: new Map(),
  203. images: new Map(),
  204. loadFromStorage() {
  205. try {
  206. const stored = GM_getValue('urlCache', '{}');
  207. const data = JSON.parse(stored);
  208. const now = Date.now();
  209. const DAY = 24 * 60 * 60 * 1000;
  210.  
  211. for (const [key, entry] of Object.entries(data)) {
  212. if (now - entry.timestamp < DAY) {
  213. this.urls.set(key, entry.url);
  214. }
  215. }
  216. } catch (error) {
  217. console.error('Cache load error:', error);
  218. }
  219. },
  220. saveToStorage: debounce(() => {
  221. const data = {};
  222. cache.urls.forEach((url, key) => {
  223. data[key] = { url, timestamp: Date.now() };
  224. });
  225. GM_setValue('urlCache', JSON.stringify(data));
  226. }, 5000)
  227. };
  228.  
  229. // Utilities
  230. function debounce(func, wait) {
  231. let timeout;
  232. return function(...args) {
  233. clearTimeout(timeout);
  234. timeout = setTimeout(() => func.apply(this, args), wait);
  235. };
  236. }
  237.  
  238. // Image loader
  239. async function loadImage(imgId, baseUrl) {
  240. if (cache.urls.has(imgId)) {
  241. return cache.urls.get(imgId);
  242. }
  243.  
  244. for (const ext of ['jpg', 'jpeg', 'png']) {
  245. const url = `${baseUrl}.${ext}`;
  246. try {
  247. const response = await fetch(url, { method: 'HEAD' });
  248. if (response.ok) {
  249. cache.urls.set(imgId, url);
  250. cache.saveToStorage();
  251. return url;
  252. }
  253. } catch (error) {
  254. continue;
  255. }
  256. }
  257. return null;
  258. }
  259.  
  260. // Preview handler
  261. function handlePreview(e, thumbnail) {
  262. const img = thumbnail.querySelector('img');
  263. if (!img) return;
  264.  
  265. const match = img.src.match(/\/(\w+)\.\w+$/);
  266. if (!match) return;
  267.  
  268. const imgId = match[1];
  269. if (preview.currentId === imgId) {
  270. preview.updatePosition(e);
  271. return;
  272. }
  273. preview.currentId = imgId;
  274.  
  275. preview.setLoading();
  276. preview.show(e);
  277.  
  278. if (cache.images.has(imgId)) {
  279. preview.setImage(cache.images.get(imgId).cloneNode(true));
  280. return;
  281. }
  282.  
  283. requestManager.clear();
  284. requestManager.add(async () => {
  285. const folder1 = imgId.substring(0, 2);
  286. const folder2 = imgId.substring(2, 4);
  287. const baseUrl = `https://cdn.donmai.us/original/${folder1}/${folder2}/${imgId}`;
  288.  
  289. try {
  290. const url = await loadImage(imgId, baseUrl);
  291. if (!url || preview.currentId !== imgId) return;
  292.  
  293. const img = new Image();
  294. img.style.cssText = 'max-width: 800px; max-height: 800px;';
  295.  
  296. img.onload = () => {
  297. if (preview.currentId === imgId) {
  298. cache.images.set(imgId, img.cloneNode(true));
  299. preview.setImage(img);
  300. }
  301. };
  302.  
  303. img.src = url;
  304. } catch (error) {
  305. console.error('Image load error:', error);
  306. }
  307. });
  308. }
  309.  
  310. // Event handler setup
  311. function setupHandlers(thumbnail) {
  312. if (thumbnail.dataset.previewInitialized) return;
  313. thumbnail.dataset.previewInitialized = 'true';
  314.  
  315. thumbnail.addEventListener('mouseenter', e => handlePreview(e, thumbnail));
  316. thumbnail.addEventListener('mouseleave', () => preview.hide());
  317. thumbnail.addEventListener('mousemove', e => preview.updatePosition(e));
  318. }
  319.  
  320. // Initialize
  321. function init() {
  322. preview.init();
  323. cache.loadFromStorage();
  324.  
  325. document.querySelectorAll('.post-preview-link').forEach(setupHandlers);
  326.  
  327. new MutationObserver(mutations => {
  328. mutations.forEach(mutation => {
  329. mutation.addedNodes.forEach(node => {
  330. if (node.classList?.contains('post-preview-link')) {
  331. setupHandlers(node);
  332. }
  333. });
  334. });
  335. }).observe(document.body, {
  336. childList: true,
  337. subtree: true
  338. });
  339. }
  340.  
  341. // Start the script
  342. init();
  343. })();