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.