This userscript adds image resolutions next to every performer image on StashDB.
Installation requires a browser extension such as Tampermonkey or Greasemonkey.
This userscript adds image resolutions next to every performer image on StashDB.
Installation requires a browser extension such as Tampermonkey or Greasemonkey.
| // ==UserScript== | |
| // @name StashDB Images | |
| // @author peolic | |
| // @version 1.70 | |
| // @description Adds image resolutions next to performer images. | |
| // @namespace https://github.com/peolic | |
| // @match https://stashdb.org/* | |
| // @grant GM.addStyle | |
| // @homepageURL https://gist.github.com/peolic/7368022947a28ef11bf44d0ae802df45 | |
| // @downloadURL https://gist.github.com/peolic/7368022947a28ef11bf44d0ae802df45/raw/stashdb-images.user.js | |
| // @updateURL https://gist.github.com/peolic/7368022947a28ef11bf44d0ae802df45/raw/stashdb-images.user.js | |
| // ==/UserScript== | |
| //@ts-check | |
| (() => { | |
| function main() { | |
| globalStyle(); | |
| dispatcher(); | |
| window.addEventListener(locationChanged, dispatcher); | |
| } | |
| async function dispatcher() { | |
| await elementReadyIn('.MainContent .LoadingIndicator', 100); | |
| const pathname = window.location.pathname.replace(/^\//, ''); | |
| const pathParts = pathname ? pathname.split(/\//g) : []; | |
| if (pathParts.length === 0) return; | |
| const [p1, p2, p3] = pathParts; | |
| // /edits | /edits/:uuid | /users/:user/edits | |
| if ( | |
| (p1 === 'edits' && !p3) | |
| || (p1 === 'users' && p2 && p3 === 'edits') | |
| ) { | |
| return await iEditCards(); | |
| } | |
| // /edits/:uuid/update | |
| // /drafts/:uuid | |
| if ( | |
| (p1 === 'edits' && p2 && p3 === 'update') | |
| || (p1 === 'drafts' && p2 && !p3) | |
| ) { | |
| return await iEditUpdatePage(p2); | |
| } | |
| if (p1 === 'performers') { | |
| // /performers/add | /performers/:uuid/edit | /performers/:uuid/merge | |
| if (p2 === 'add' || (p2 && ['edit', 'merge'].includes(p3))) { | |
| return await iPerformerEditPage(p3); | |
| } | |
| // /performers/:uuid | |
| if (p2 && !p3) { | |
| await iPerformerPage(); | |
| if (window.location.hash === '#edits') { | |
| await iEditCards(); | |
| } | |
| return; | |
| } | |
| } | |
| if (p1 === 'scenes') { | |
| // /scenes/add | /scenes/:uuid/edit | |
| if (p2 === 'add' || (p2 && p3 === 'edit')) { | |
| return await iSceneEditPage(); | |
| } | |
| // /scenes/:uuid | |
| if (p2 && !p3) { | |
| await iScenePage(); | |
| if (window.location.hash === '#edits') { | |
| await iEditCards(); | |
| } | |
| return; | |
| } | |
| return; | |
| } | |
| if (p1 === 'studios') { | |
| // /studios/add | /studios/:uuid/edit | |
| if (p2 === 'add' || (p2 && p3 === 'edit')) { | |
| return await iStudioEditPage(); | |
| } | |
| // /studios/:uuid | |
| if (p2 && !p3) { | |
| if (window.location.hash === '#edits') { | |
| await iEditCards(); | |
| } | |
| return; | |
| } | |
| return; | |
| } | |
| } | |
| function globalStyle() { | |
| //@ts-expect-error | |
| GM.addStyle(` | |
| .image-resolution { | |
| position: absolute; | |
| left: 0; | |
| bottom: 0; | |
| background-color: #2fb59c; | |
| transition: opacity .2s ease; | |
| font-weight: bold; | |
| padding: 0 .5rem; | |
| } | |
| a.resized-image-marker { | |
| display: inline-block; | |
| } | |
| a.resized-image-marker:hover { | |
| color: var(--bs-cyan); | |
| } | |
| `); | |
| } | |
| /** | |
| * @typedef {Object} ImageData | |
| * @property {string} id | |
| * @property {number} width | |
| * @property {number} height | |
| * @property {string} url | |
| */ | |
| async function iEditCards() { | |
| const selector = '.ImageChangeRow > * > .ImageChangeRow'; | |
| const isLoading = !!document.querySelector('.LoadingIndicator'); | |
| if (!await elementReadyIn(selector, isLoading ? 5000 : 2000)) return; | |
| /** | |
| * @param {HTMLImageElement} img | |
| */ | |
| const handleImage = async (img) => { | |
| if (img.dataset.injectedResolution) return; | |
| // https://github.com/stashapp/stash-box/blob/v0.6.3/frontend/src/components/imageChangeRow/ImageChangeRow.tsx#L32-L34 | |
| const imgImage = /** @type {HTMLDivElement} */ (img.closest('.Image')); | |
| const resolution = /** @type {HTMLDivElement} */ (imgImage?.nextElementSibling); | |
| const imgFiber = getReactFiber(img); | |
| /** @type {ImageData} */ | |
| const imgData = imgFiber?.return?.memoizedProps?.image; | |
| const [width, height] = resolveDimensions(imgData, img); | |
| const isSVG = width <= 0 && height <= 0; | |
| img.dataset.injectedResolution = 'true'; | |
| const imgLink = document.createElement('a'); | |
| imgLink.href = img.src; | |
| imgLink.target = '_blank'; | |
| imgLink.classList.add('ImageChangeRow-image'); | |
| imgImage.before(imgLink); | |
| imgLink.append(imgImage, resolution); | |
| resolution.innerText = isSVG ? '\u{221E} x \u{221E}' : `${width} x ${height}`; | |
| const resized = makeResizedMarker(img.src, imgData, 'ms-1'); | |
| if (resized) resolution.appendChild(resized); | |
| }; | |
| /** @type {HTMLDivElement[]} */ | |
| (Array.from(document.querySelectorAll(selector))).forEach((cr) => { | |
| /** @type {HTMLImageElement[]} */ | |
| (Array.from(cr.querySelectorAll('.ImageChangeRow-image img'))).forEach((img) => { | |
| imageReady(img).then( | |
| () => handleImage(img), | |
| () => setTimeout(handleAdBlocked, 100, img), | |
| ); | |
| }) | |
| }); | |
| } // iEditCards | |
| async function iPerformerPage() { | |
| if (!await elementReadyIn('.PerformerInfo', 1000)) return; | |
| const carousel = ( | |
| /** @type {HTMLDivElement} */ | |
| (await elementReadyIn('.performer-photo .image-carousel-img', 200)) | |
| ); | |
| if (!carousel || carousel.dataset.injectedResolution) return; | |
| carousel.dataset.injectedResolution = 'true'; | |
| const subtitle = /** @type {HTMLHeadingElement} */ (document.querySelector('.performer-photo h5')); | |
| const position = document.createElement('span'); | |
| subtitle.appendChild(position); | |
| while (subtitle.firstChild && !subtitle.firstChild.isSameNode(position)) { | |
| position.appendChild(subtitle.firstChild); | |
| } | |
| const separator = document.createElement('span'); | |
| separator.classList.add('mx-2'); | |
| separator.innerText = '/'; | |
| const resolution = document.createElement('span'); | |
| subtitle.append(separator, resolution); | |
| /** | |
| * @param {HTMLImageElement | null | undefined} [img] | |
| * @param {ImageData | undefined} [imgData] | |
| */ | |
| const updateResolution = (img, imgData) => { | |
| if (img === null) | |
| resolution.innerText = '??? x ???'; | |
| else if (!img) | |
| resolution.innerText = '... x ...'; | |
| else if (!imgData) | |
| resolution.innerText = `${img.naturalWidth} x ${img.naturalHeight}`; | |
| else | |
| resolution.innerText = `${imgData.width} x ${imgData.height}`; | |
| const resized = makeResizedMarker(img?.src, img && imgData, 'ms-1'); | |
| if (resized) resolution.appendChild(resized); | |
| }; | |
| const handleExistingImage = async () => { | |
| const img = /** @type {HTMLImageElement} */ (carousel.querySelector('img')); | |
| const imgFiber = getReactFiber(/** @type {HTMLDivElement} */ (carousel.querySelector(':scope > .Image'))); | |
| /** @type {ImageData} */ | |
| const imgData = imgFiber?.return?.memoizedProps?.images; | |
| updateResolution(); | |
| imageReady(img).then( | |
| () => updateResolution(img, imgData), | |
| () => { | |
| updateResolution(null, imgData) | |
| setTimeout(handleAdBlocked, 100, img, () => { | |
| const error = /** @type {HTMLElement} */ ( | |
| /** @type {HTMLElement} */ (img.nextElementSibling).firstElementChild | |
| ); | |
| error.innerText += '\n\nThis image should be visible,\nbut an Ad Blocker is blocking it'; | |
| }); | |
| }, | |
| ); | |
| }; | |
| await handleExistingImage(); | |
| new MutationObserver(handleExistingImage).observe(carousel, { childList: true }); | |
| } // iPerformerPage | |
| async function iScenePage() { | |
| const selector = '.ScenePhoto'; | |
| const isLoading = !!document.querySelector('.LoadingIndicator'); | |
| const scenePhoto = await elementReadyIn(selector, isLoading ? 5000 : 2000); | |
| if (!scenePhoto) return; | |
| const img = /** @type {HTMLImageElement | null} */ (scenePhoto.querySelector('img:not([src=""])')); | |
| if (!img) return; | |
| const imgFiber = getReactFiber(img); | |
| /** @type {ImageData} */ | |
| const imgData = imgFiber?.return?.memoizedProps?.image; | |
| const resized = makeResizedMarker(img.src, imgData, 'position-relative'); | |
| if (resized) { | |
| const container = document.createElement('div'); | |
| container.classList.add('position-absolute', 'end-0'); | |
| setStyles(resized, { top: '-26px' }); | |
| resized.title += ` (${imgData.width} x ${imgData.height})`; | |
| container.appendChild(resized); | |
| scenePhoto.prepend(container); | |
| } | |
| } // iScenePage | |
| function handleEditPage() { | |
| /** @param {HTMLDivElement} ii */ | |
| const handleExistingImage = (ii) => { | |
| const imgFiber = getReactFiber(ii); | |
| /** @type {ImageData} */ | |
| const imgData = imgFiber?.return?.memoizedProps?.image; | |
| const img = /** @type {HTMLImageElement} */ (ii.querySelector('img')); | |
| if (img.dataset.injectedResolution) return; | |
| const [width, height] = resolveDimensions(imgData, img); | |
| const isSVG = width <= 0 && height <= 0; | |
| img.dataset.injectedResolution = 'true'; | |
| const imgLink = document.createElement('a'); | |
| imgLink.classList.add('text-center'); | |
| imgLink.href = img.src; | |
| imgLink.target = '_blank'; | |
| imgLink.title = 'Open in new tab'; | |
| const icon = document.createElement('h4'); | |
| icon.innerText = '⎋'; | |
| icon.classList.add('position-absolute', 'end-0', 'lh-1', 'mb-0'); | |
| const resolution = document.createElement('div'); | |
| resolution.innerText = isSVG ? '\u{221E} x \u{221E}' : `${width} x ${height}`; | |
| imgLink.append(icon, resolution); | |
| ii.appendChild(imgLink); | |
| const resized = makeResizedMarker(img.src, imgData, 'position-absolute'); | |
| if (resized) { | |
| const resizedC = document.createElement('div'); | |
| resizedC.classList.add('position-relative'); | |
| resizedC.appendChild(resized); | |
| imgLink.before(resizedC); | |
| } | |
| }; | |
| /** @type {HTMLDivElement[]} */ | |
| const existingImages = (Array.from(document.querySelectorAll('.EditImages .ImageInput'))); | |
| existingImages.forEach((ii) => { | |
| const img = /** @type {HTMLImageElement} */ (ii.querySelector('img')); | |
| if (!img) return; | |
| imageReady(img).then( | |
| () => handleExistingImage(ii), | |
| () => setTimeout(handleAdBlocked, 100, img), | |
| ); | |
| }); | |
| // Watch for new images (images tab) | |
| const imageContainer = /** @type {HTMLElement} */ (document.querySelector('.EditImages-images')); | |
| new MutationObserver((mutationRecords, observer) => { | |
| mutationRecords.forEach((record) => { | |
| record.addedNodes.forEach((node) => { | |
| if (node.nodeType !== node.ELEMENT_NODE || node.nodeName !== 'DIV') return; | |
| const element = /** @type {HTMLDivElement} */ (node); | |
| if (!element.matches('.ImageInput')) return; | |
| const img = /** @type {HTMLImageElement} */ (element.querySelector('img')); | |
| imageReady(img).then( | |
| () => handleExistingImage(element), | |
| () => setTimeout(handleAdBlocked, 100, img), | |
| ); | |
| }); | |
| }); | |
| }).observe(imageContainer, { childList: true }); | |
| // Watch for image input | |
| const imageInputContainer = /** @type {HTMLDivElement} */ (document.querySelector('.EditImages-input-container')); | |
| new MutationObserver((mutationRecords, observer) => { | |
| mutationRecords.forEach((record) => { | |
| const { target } = record; | |
| if (target.nodeType !== target.ELEMENT_NODE || target.nodeName !== 'DIV') return; | |
| const element = /** @type {HTMLDivElement} */ (target); | |
| // Add image resolution on image input | |
| if (element.matches('.EditImages-image')) { | |
| record.addedNodes.forEach((node) => { | |
| if (node.nodeType !== node.ELEMENT_NODE || node.nodeName !== 'IMG') return; | |
| const img = /** @type {HTMLImageElement} */ (node); | |
| imageReady(img, false).then(() => { | |
| if (img.dataset.injectedResolution) return; | |
| img.dataset.injectedResolution = 'true'; | |
| img.after(makeImageResolutionOverlay(img)); | |
| }); | |
| }); | |
| return; | |
| } | |
| // Remove image resolution on cancel / upload | |
| if (element.matches('.EditImages-drop')) { | |
| record.removedNodes.forEach((node) => { | |
| if (node.nodeType !== node.ELEMENT_NODE || node.nodeName !== 'IMG') return; | |
| /** @type {HTMLDivElement} */ | |
| (imageInputContainer.querySelector('div.image-resolution')).remove(); | |
| }); | |
| return; | |
| } | |
| }); | |
| }).observe(imageInputContainer, { childList: true, subtree: true }); | |
| // Watch for new images (confirm tab) | |
| const confirmTab = /** @type {HTMLDivElement} */ (document.querySelector('form div[id$="-tabpane-confirm"]')); | |
| new MutationObserver(() => iEditCards()) | |
| .observe(confirmTab, { childList: true, subtree: true }); | |
| } // handleEditPage | |
| /** @param {string} action */ | |
| async function iPerformerEditPage(action) { | |
| let ready = false; | |
| if (action === 'merge') { | |
| // SPECIAL CASE: indenfinitely wait for the merge form to appear first | |
| ready = await Promise.race([ | |
| elementReady('.PerformerMerge .PerformerForm').then(() => true), | |
| new Promise((resolve) => | |
| window.addEventListener(locationChanged, () => resolve(false), { once: true })), | |
| ]); | |
| } else { | |
| ready = !!await elementReadyIn('.PerformerForm', 1000); | |
| } | |
| if (!ready) return; | |
| handleEditPage(); | |
| } // iPerformerEditPage | |
| async function iSceneEditPage() { | |
| if (!await elementReadyIn('.SceneForm', 1000)) return; | |
| handleEditPage(); | |
| } // iSceneEditPage | |
| async function iStudioEditPage() { | |
| if (!await elementReadyIn('.StudioForm', 1000)) return; | |
| handleEditPage(); | |
| } // iStudioEditPage | |
| /** | |
| * @param {string} editId | |
| */ | |
| async function iEditUpdatePage(editId) { | |
| const form = /** @type {HTMLFormElement} */ (await elementReadyIn('main form', 2000)); | |
| switch (Array.from(form.classList).find((c) => c.endsWith('Form'))) { | |
| case 'PerformerForm': | |
| return await iPerformerEditPage('edit'); | |
| case 'SceneForm': | |
| return await iSceneEditPage(); | |
| case 'StudioForm': | |
| return await iStudioEditPage(); | |
| case 'TagForm': | |
| default: | |
| return; | |
| } | |
| } // iEditUpdatePage | |
| /** | |
| * @param {number} ms | |
| */ | |
| const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| /** | |
| * @param {string} selector | |
| * @param {number} [timeout] fail after, in milliseconds | |
| */ | |
| const elementReadyIn = (selector, timeout) => { | |
| /** @type {Promise<Element | null>[]} */ | |
| const promises = [elementReady(selector)]; | |
| if (timeout) promises.push(wait(timeout).then(() => null)); | |
| return Promise.race(promises); | |
| }; | |
| /** | |
| * @param {HTMLElement} el | |
| * @param {MutationObserverInit} options | |
| * @param {number} timeout | |
| */ | |
| const waitForFirstChange = async (el, options, timeout) => { | |
| return Promise.race([ | |
| wait(timeout), | |
| /** @type {Promise<void>} */ (new Promise((resolve) => { | |
| new MutationObserver((mutationRecords, observer) => { | |
| observer.disconnect(); | |
| resolve(); | |
| }).observe(el, options); | |
| })), | |
| ]); | |
| }; | |
| /** | |
| * @param {Element} el | |
| * @returns {Record<string, any> | undefined} | |
| */ | |
| const getReactFiber = (el) => | |
| el[Object.getOwnPropertyNames(el).find((p) => p.startsWith('__reactFiber$')) || '']; | |
| /** | |
| * @param {HTMLImageElement} img | |
| * @param {boolean} [error=true] Reject promise on load error? | |
| * @returns {Promise<void>} | |
| */ | |
| async function imageReady(img, error = true) { | |
| if (img.complete && img.naturalHeight !== 0) return; | |
| return new Promise((resolve, reject) => { | |
| if (!error) { | |
| img.addEventListener('load', () => resolve(), { once: true }); | |
| return; | |
| } | |
| const onLoad = () => { | |
| img.removeEventListener('error', onError); | |
| resolve(); | |
| } | |
| const onError = (/** @type {ErrorEvent} */ event) => { | |
| img.removeEventListener('load', onLoad); | |
| reject(event.message || 'unknown'); | |
| } | |
| img.addEventListener('load', onLoad, { once: true }); | |
| img.addEventListener('error', onError, { once: true }); | |
| }); | |
| } | |
| /** | |
| * @param {ImageData | null | undefined} imgData | |
| * @param {HTMLImageElement} img | |
| * @returns {[width: number, height: number]} | |
| */ | |
| const resolveDimensions = (imgData, img) => | |
| [imgData?.width ?? img.naturalWidth, imgData?.height ?? img.naturalHeight]; | |
| /** | |
| * @template {HTMLElement | SVGSVGElement} E | |
| * @param {E} el | |
| * @param {Partial<CSSStyleDeclaration>} styles | |
| * @returns {E} | |
| */ | |
| function setStyles(el, styles) { | |
| Object.assign(el.style, styles); | |
| return el; | |
| } | |
| /** | |
| * @param {HTMLImageElement} img | |
| * @param {() => void | undefined} after | |
| */ | |
| function handleAdBlocked(img, after) { | |
| if (!(new URL(img.src)).pathname.startsWith('/images/ad')) return; | |
| for (const attr of img.attributes) { | |
| if (/^[\w\d]{9}/.test(attr.name)) { | |
| img.attributes.removeNamedItem(attr.name); | |
| break; | |
| } | |
| } | |
| if (after !== undefined) return after(); | |
| img.alt = 'This image should be visible,\nbut an Ad Blocker is blocking it'; | |
| setStyles(img, { | |
| whiteSpace: 'pre', | |
| border: '3px solid red', | |
| padding: '5px', | |
| height: 'min-content', | |
| }); | |
| } | |
| /** | |
| * @param {HTMLImageElement} img | |
| * @returns {HTMLDivElement} | |
| */ | |
| function makeImageResolutionOverlay(img) { | |
| const imgRes = document.createElement('div'); | |
| imgRes.classList.add('image-resolution'); | |
| const isSVG = img.naturalWidth <= 0 && img.naturalHeight <= 0; | |
| imgRes.innerText = isSVG ? '\u{221E} x \u{221E}' : `${img.naturalWidth} x ${img.naturalHeight}`; | |
| img.addEventListener('mouseover', () => imgRes.style.opacity = '0'); | |
| img.addEventListener('mouseout', () => imgRes.style.opacity = ''); | |
| return imgRes; | |
| } | |
| /** | |
| * @param {String} [imgSrc] | |
| * @param {ImageData | null} [imgData] | |
| * @param {...string} className | |
| * @returns {HTMLAnchorElement | HTMLSpanElement | null} | |
| */ | |
| const makeResizedMarker = (imgSrc, imgData, ...className) => { | |
| if (!(imgSrc && imgData)) | |
| return null; | |
| const sizeParam = (new URL(imgSrc)).searchParams.get('size'); | |
| if (!sizeParam || sizeParam === 'full' || Math.max(imgData.width, imgData.height) <= Number(sizeParam)) | |
| return null; | |
| const resized = document.createElement(true || isDev() ? 'a' : 'span'); | |
| resized.innerText = '(🗗)'; | |
| resized.title = 'View full size'; | |
| resized.classList.add('resized-image-marker', ...className); | |
| if (resized instanceof HTMLAnchorElement) { | |
| resized.href = imgData.url; | |
| resized.target = '_blank'; | |
| } | |
| return resized; | |
| } | |
| const isDev = () => { | |
| const profile = /** @type {HTMLAnchorElement} */ (document.querySelector('#root nav a[href^="/users/"]')); | |
| return profile && ['peolic', 'root'].includes(profile.innerText); | |
| }; | |
| // Based on: https://dirask.com/posts/JavaScript-on-location-changed-event-on-url-changed-event-DKeyZj | |
| const locationChanged = (function() { | |
| const { pushState, replaceState } = history; | |
| // @ts-expect-error | |
| const prefix = GM.info.script.name | |
| .toLowerCase() | |
| .trim() | |
| .replace(/[^a-z0-9 -]/g, '') | |
| .replace(/\s+/g, '-'); | |
| const eventLocationChange = new Event(`${prefix}$locationchange`); | |
| history.pushState = function(...args) { | |
| pushState.apply(history, args); | |
| window.dispatchEvent(new Event(`${prefix}$pushstate`)); | |
| window.dispatchEvent(eventLocationChange); | |
| } | |
| history.replaceState = function(...args) { | |
| replaceState.apply(history, args); | |
| window.dispatchEvent(new Event(`${prefix}$replacestate`)); | |
| window.dispatchEvent(eventLocationChange); | |
| } | |
| window.addEventListener('popstate', function() { | |
| window.dispatchEvent(eventLocationChange); | |
| }); | |
| return eventLocationChange.type; | |
| })(); | |
| // MIT Licensed | |
| // Author: jwilson8767 | |
| // https://gist.github.com/jwilson8767/db379026efcbd932f64382db4b02853e | |
| /** | |
| * Waits for an element satisfying selector to exist, then resolves promise with the element. | |
| * Useful for resolving race conditions. | |
| * | |
| * @param {string} selector | |
| * @returns {Promise<Element>} | |
| */ | |
| function elementReady(selector) { | |
| return new Promise((resolve, reject) => { | |
| let el = document.querySelector(selector); | |
| if (el) {resolve(el);} | |
| new MutationObserver((mutationRecords, observer) => { | |
| // Query for elements matching the specified selector | |
| Array.from(document.querySelectorAll(selector)).forEach((element) => { | |
| resolve(element); | |
| //Once we have resolved we don't need the observer anymore. | |
| observer.disconnect(); | |
| }); | |
| }) | |
| .observe(document.documentElement, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| }); | |
| } | |
| main(); | |
| })(); |
Comments are disabled for this gist.