Last active
June 7, 2018 10:51
Star
You must be signed in to star a gist
Weight Weenies lightbox
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Weight Weenies lightbox | |
// @description Adds lightbox-style image viewer for attached images | |
// @version 1.32 | |
// @author Klaster_1 | |
// @match https://weightweenies.starbike.com/forum* | |
// @grant none | |
// @icon http://weightweenies.starbike.com/images/weenie.gif | |
// @downloadURL https://gist.githubusercontent.com/Klaster1/188723c7542a2c2510312227bfee2042/raw/ww_lightbox.user.js | |
// @updateURL https://gist.githubusercontent.com/Klaster1/188723c7542a2c2510312227bfee2042/raw/ww_lightbox.user.js | |
// ==/UserScript== | |
/** | |
* @typedef {Object} Image | |
* @prop {string} src | |
* @prop {string} thumb | |
* @prop {string} caption | |
* @prop {number} loadingProgress | |
* @prop {boolean} isVisible | |
*/ | |
/** | |
* @typedef {[number, number]} Coords | |
*/ | |
/** | |
* @typedef {Object} Zoom | |
* @prop {number} ratio | |
* @prop {Coords} origin | |
* @prop {Coords} translate | |
*/ | |
/** | |
* @typedef {Object} StateValue | |
* @prop {Array<Image>} images | |
* @prop {number} currentImageIndex | |
* @prop {boolean} visible | |
* @prop {Zoom} zoom | |
*/ | |
class State { | |
/** @param {[Image]} [images=[]] */ | |
constructor(images = []) { | |
/** @type {StateValue} */ | |
this.value = { | |
...State.getDefaults(), | |
images | |
} | |
} | |
_getPrevIndex() { | |
return this.value.currentImageIndex ? this.value.currentImageIndex - 1 : this.value.images.length - 1 | |
} | |
_getNextIndex() { | |
return (this.value.currentImageIndex + 1) % this.value.images.length | |
} | |
/** | |
* Returns image index by it's source | |
* @param {string} src Image src | |
* @return {number} Image index | |
*/ | |
_getSrcIndex(src) { | |
return this.value.images.findIndex(i => i.src === src) | |
} | |
/** | |
* @param {number} visibleIndex Index of image to load | |
*/ | |
_imageIsVisibleReducer(visibleIndex) { | |
return this.value.images.map((image, index) => index === visibleIndex ? {...image, isVisible: true} : image) | |
} | |
next() { | |
if (!this.value.visible) return | |
const newIndex = this._getNextIndex() | |
this.value = { | |
...this.value, | |
visible: true, | |
zoom: State.getDefaults().zoom, | |
currentImageIndex: newIndex, | |
images: this._imageIsVisibleReducer(newIndex) | |
} | |
} | |
prev() { | |
if (!this.value.visible) return | |
const newIndex = this._getPrevIndex() | |
this.value = { | |
...this.value, | |
visible: true, | |
zoom: State.getDefaults().zoom, | |
currentImageIndex: newIndex, | |
images: this._imageIsVisibleReducer(newIndex) | |
} | |
} | |
/** @param {number} index */ | |
showByIndex(index) { | |
if (!this.value.visible) return | |
this.value = { | |
...this.value, | |
visible: true, | |
zoom: State.getDefaults().zoom, | |
currentImageIndex: index, | |
images: this._imageIsVisibleReducer(index) | |
} | |
} | |
/** | |
* Starts image loading without showing it | |
* @param {number} index | |
*/ | |
preloadByIndex(index) { | |
this.value = { | |
...this.value, | |
images: this._imageIsVisibleReducer(index) | |
} | |
} | |
preloadPrev() { | |
this.value = { | |
...this.value, | |
images: this._imageIsVisibleReducer(this._getPrevIndex()) | |
} | |
} | |
preloadNext() { | |
this.value = { | |
...this.value, | |
images: this._imageIsVisibleReducer(this._getNextIndex()) | |
} | |
} | |
/** | |
* @param {string} src | |
*/ | |
preloadBySrc(src) { | |
this.value = { | |
...this.value, | |
images: this._imageIsVisibleReducer(this._getSrcIndex(src)) | |
} | |
} | |
/** | |
* @param {string} src | |
*/ | |
showBySrc(src) { | |
const newIndex = this._getSrcIndex(src) | |
this.value = { | |
...this.value, | |
visible: true, | |
currentImageIndex: newIndex, | |
images: this._imageIsVisibleReducer(newIndex) | |
} | |
} | |
/** | |
* @param {string} src | |
* @param {number} loadingProgress | |
*/ | |
updateImageLoadingProgress(src, loadingProgress) { | |
this.value = { | |
...this.value, | |
images: this.value.images.map(i => ( | |
i.src === src ? {...i, loadingProgress} : i | |
)) | |
} | |
} | |
hide() { | |
if (!this.value.visible) return | |
this.value = { | |
...this.value, | |
visible: false | |
} | |
} | |
zoom({ratio = 1, origin = [0, 0], translate = [0, 0]}) { | |
const newRatio = Math.min(Math.max(this.value.zoom.ratio * ratio, 1), 5) | |
this.value = { | |
...this.value, | |
zoom: { | |
ratio: newRatio, | |
origin: newRatio < this.value.zoom.ratio | |
? this.value.zoom.origin | |
: origin, | |
translate | |
} | |
} | |
} | |
/** | |
* @returns {StateValue} | |
*/ | |
static getDefaults() { | |
return { | |
images: [], | |
currentImageIndex: null, | |
visible: false, | |
zoom: { | |
ratio: 1, | |
origin: [0, 0], | |
translate: [0, 0] | |
} | |
} | |
} | |
} | |
{ | |
// Framework | |
const pluck = (o, ...keys) => keys.reduce((o, k) => { | |
return (o !== void 0 && o !== null) | |
? k instanceof Function ? k(o) : o[k] | |
: void 0 | |
}, o) | |
/** | |
* @template T | |
* @param {T} state | |
*/ | |
const watch = (state, listeners) => { | |
const ROOT = 'value' | |
const prevValues = new WeakMap | |
const runListeners = (state) => { | |
for (let [path, listener] of listeners) { | |
const currentValue = pluck(state, ROOT, ...path) | |
if (prevValues.get(path) !== currentValue) { | |
prevValues.set(path, currentValue) | |
listener(currentValue, state) | |
} | |
} | |
} | |
/** @type {T} */ | |
const wrappedState = new Proxy(state, { | |
set(state, key, value) { | |
Reflect.set(...arguments) | |
if (key === ROOT) runListeners(state) | |
return true | |
} | |
}) | |
runListeners(state) | |
return wrappedState | |
} | |
// Template | |
const fragment = document.createRange().createContextualFragment(` | |
<figure class='ww-lb-container ww-lb-container-hidden'> | |
<span class='ww-lb-current-position'></span> | |
<button type="button" class='ww-lb-prev ww-lb-nav' title='Previous'> | |
<span class='ww-lb-nav-icon'>◀</span> | |
</button> | |
<picture class='ww-lb-image-container'></picture> | |
<figcaption class='ww-lb-caption'> | |
<a class='ww-lb-link' target='_blank'></a> | |
</figcaption> | |
<button type="button" class='ww-lb-next ww-lb-nav' title='Next'> | |
<span class='ww-lb-nav-icon'>▶</span> | |
</button> | |
<button type="button" class='ww-lb-close ww-lb-nav' title='Close'>🞩</button> | |
<nav class='ww-lb-thumbs'></nav> | |
</figure> | |
<style> | |
.ww-lb-container { | |
--text-color: white; | |
--button-width: 75px; | |
--transition-duration: 0.15s; | |
--close-height: 50px; | |
color: var(--text-color); | |
position: fixed; | |
top: 0; | |
right: 0; | |
bottom: 0; | |
left: 0; | |
z-index: 100; | |
background: rgba(0,0,0,0.7); | |
} | |
.ww-lb-container-hidden { | |
pointer-events: none; | |
opacity: 0; | |
} | |
@keyframes ww-lb-container-appear { | |
from { | |
opacity: 0; | |
} | |
to { | |
opacity: 1; | |
} | |
} | |
.ww-lb-container-appear { | |
animation: ww-lb-container-appear 0.1s; | |
} | |
.ww-lb-container-disappear { | |
animation: ww-lb-container-appear 0.1s reverse; | |
} | |
.ww-lb-close { | |
grid-area: close; | |
} | |
.ww-lb-current-position { | |
grid-area: position; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
.ww-lb-container:not([hidden]) { | |
display: grid; | |
grid-template-rows: var(--close-height) 1fr minmax(auto, auto); | |
grid-template-columns: var(--button-width) 1fr var(--button-width); | |
grid-template-areas: | |
"position thumbs close" | |
"prev image next" | |
"caption caption caption"; | |
} | |
.ww-lb-image-container { | |
display: flex; | |
grid-area: image; | |
overflow: hidden; | |
} | |
.ww-lb-image { | |
margin: auto; | |
max-height: 100%; | |
max-width: 100%; | |
transition: filter var(--transition-duration); | |
image-orientation: from-image; | |
} | |
.ww-lb-nav { | |
color: var(--text-color); | |
border: none; | |
background: transparent; | |
font-size: 36px; | |
transition: background var(--transition-duration); | |
position: relative; | |
} | |
.ww-lb-nav:hover { | |
background: rgba(0, 0, 0, 0.1); | |
} | |
.ww-lb-nav-icon { | |
position: absolute; | |
top: calc(50vh - 45px * 0.5); | |
left: 0; | |
right: 0; | |
pointer-events: none; | |
} | |
.ww-lb-prev { | |
grid-row-start: position; | |
grid-row-end: prev; | |
grid-column: prev; | |
} | |
.ww-lb-next { | |
grid-row-start: close; | |
grid-row-end: next; | |
grid-column: next; | |
} | |
.ww-lb-caption { | |
grid-area: caption; | |
color: var(--text-color); | |
background: rgba(0,0,0,0.5); | |
text-align: center; | |
font-size: 13px; | |
padding: 0.5em; | |
box-sizing: border-box; | |
} | |
a.ww-lb-link { | |
white-space: pre-line; | |
color: inherit; | |
font-weight: normal; | |
} | |
.ww-lb-one-image .ww-lb-prev, | |
.ww-lb-one-image .ww-lb-next, | |
.ww-lb-one-image .ww-lb-current-position, | |
.ww-lb-one-image .ww-lb-thumbs { | |
display: none; | |
} | |
.ww-lb-thumbs { | |
grid-area: thumbs; | |
display: flex; | |
justify-content: center; | |
overflow: hidden; | |
padding: 5px; | |
} | |
.ww-lb-thumb { | |
height: 100%; | |
margin: 0 5px; | |
image-orientation: from-image; | |
cursor: pointer; | |
} | |
.ww-lb-thumb-current { | |
outline: 1px solid white; | |
} | |
</style> | |
`) | |
// DOM utils | |
const LEFT_MOUSE_BUTTON = 0 | |
const [link, loadingIndicator, currentPosition] = [ | |
'.ww-lb-link', | |
'.ww-lb-loading-indicator', | |
'.ww-lb-current-position' | |
].map(s => fragment.querySelector(s)) | |
const container = fragment.querySelector('.ww-lb-container') | |
const imageContainer = fragment.querySelector('.ww-lb-image-container') | |
const thumbs = fragment.querySelector('.ww-lb-thumbs') | |
document.body.append(fragment) | |
const IMAGE_SELECTOR = '.postimage[src*="./download"]:not([src*="mode=view"])' | |
const fullSrc = (img) => `${img.src.replace(/t=1|mode=view/, '')}mode=view` | |
const is = (e, s) => s.split(',').some(s=>e.target.matches(s.trim())) | |
/** | |
* @param {HTMLImageElement} img | |
* @returns {string} | |
*/ | |
const getCaption = img => { | |
if (img.onerror) { | |
// prosilver | |
const fileComment = img.closest('dl').querySelector('dd') | |
const title = img.title | |
return fileComment | |
? `${fileComment.innerText}\n${title}` | |
: title | |
} else { | |
// subsilver2 | |
const caption = img.closest('td.row2') || | |
img.closest('td.row1') || | |
img.closest('.attachcontent') | |
return caption | |
? Array.from( | |
caption.querySelectorAll('.gensmall'), | |
n => n.innerText | |
).join(`\n`).replace('File comment:', '') | |
: '' | |
} | |
} | |
/** @returns {Array<Image>} */ | |
const getImages = () => Array.from( | |
document.querySelectorAll(IMAGE_SELECTOR), | |
/** @param {HTMLImageElement} img */ | |
(img) => ({ | |
src: fullSrc(img), | |
thumb: img.src, | |
caption: getCaption(img), | |
loadingProgress: -1 | |
}) | |
) | |
// Model -> DOM | |
/** @param {StateValue} state */ | |
const getCurrentPosition = (state) => `${state.currentImageIndex+1} / ${state.images.length}` | |
/** @param {StateValue} state */ | |
const getCurrentImage = (state) => state.images[state.currentImageIndex] | |
const state = watch(new State(getImages()), new Map([ | |
[['visible'], function visiblehandler(visible) { | |
if (!visiblehandler.skippedOnce) { | |
visiblehandler.skippedOnce = true | |
return | |
} | |
if (visible) { | |
container.addEventListener('animationend', () => { | |
container.classList.remove('ww-lb-container-hidden') | |
container.classList.remove('ww-lb-container-appear') | |
}, {once: true}) | |
container.classList.add('ww-lb-container-appear') | |
} else { | |
container.addEventListener('animationend', () => { | |
container.classList.add('ww-lb-container-hidden') | |
container.classList.remove('ww-lb-container-disappear') | |
}, {once: true}) | |
container.classList.add('ww-lb-container-disappear') | |
} | |
}], | |
[[getCurrentPosition], (value) => currentPosition.textContent = value], | |
[[getCurrentImage, 'caption'], (value) => link.textContent = value], | |
[ | |
['images', 'length'], | |
/** | |
* @param {number} value | |
* @param {State} state | |
*/ | |
(value, state) => { | |
if (value === 1) container.classList.add('ww-lb-one-image') | |
state.value.images.forEach(image => { | |
// Thumbs | |
{ | |
const el = document.createElement('img') | |
el.classList.add('ww-lb-thumb') | |
el.src = image.thumb | |
el.title = image.caption | |
thumbs.append(el) | |
} | |
// Images | |
{ | |
const el = document.createElement('img') | |
el.classList.add('ww-lb-image') | |
imageContainer.append(el) | |
} | |
}) | |
} | |
], | |
[ | |
['currentImageIndex'], | |
/** | |
* @param {number} currentIndex | |
* @param {State} state | |
*/ | |
(currentIndex, state) => { | |
Array.from(thumbs.children).forEach( | |
/** @param {HTMLImageElement} thumb */ | |
(thumb, index) => { | |
thumb.classList.toggle('ww-lb-thumb-current', index === currentIndex) | |
if (index === currentIndex) thumb.scrollIntoView() | |
} | |
) | |
Array.from(imageContainer.children).forEach( | |
/** @param {HTMLImageElement} image */ | |
(image, index) => { | |
const currentImage = getCurrentImage(state.value) | |
if (!currentImage) return | |
image.classList.toggle('ww-lb-image-current', index === currentImage) | |
if (index === currentIndex) { | |
image.hidden = false | |
link.href = currentImage.src | |
} else { | |
image.hidden = true | |
delete image.style.transform | |
delete image.style.transformOrigin | |
} | |
} | |
) | |
} | |
], | |
[ | |
['images'], | |
/** | |
* @param {Array<Image>} images | |
*/ | |
(images) => { | |
Array.from(imageContainer.children).forEach((image, index) => { | |
if (images[index].isVisible) image.src = images[index].src | |
}) | |
} | |
], | |
[['zoom'], ({ratio, origin: [ox, oy], translate: [tx, ty]}) => { | |
const image = imageContainer.querySelector('.ww-lb-image:not([hidden])') | |
if (!image) return | |
image.style.transform = `scale(${ratio}) translate(${tx}px, ${ty}px)` | |
image.style.transformOrigin = `${ox}px ${oy}px` | |
}] | |
])) | |
// Events -> model | |
document.addEventListener('load', (e) => { | |
if (!is(e, '.ww-lb-image')) return | |
state.updateImageLoadingProgress(fullSrc(e.target), 1) | |
}) | |
document.addEventListener('wheel', (e) => { | |
if (!is(e, '.ww-lb-image')) return | |
e.preventDefault() | |
state.zoom({ | |
ratio: 1 - 0.15 * (e.deltaY / Math.abs(e.deltaY)), | |
origin: [e.layerX, e.layerY] | |
}) | |
}) | |
document.addEventListener('click', (e) => { | |
if (e.button !== LEFT_MOUSE_BUTTON) return | |
if (is(e, IMAGE_SELECTOR)) { | |
e.preventDefault() | |
state.showBySrc(fullSrc(e.target)) | |
} | |
if (is(e, '.ww-lb-container, .ww-lb-image-container, .ww-lb-close')) | |
state.hide() | |
if (is(e, '.ww-lb-prev')) state.prev() | |
if (is(e, '.ww-lb-next, .ww-lb-image')) state.next() | |
if (is(e, '.ww-lb-thumb')) state.showByIndex([...e.target.parentElement.children].indexOf(e.target)) | |
}) | |
document.addEventListener('mouseover', (e) => { | |
if (is(e, IMAGE_SELECTOR)) return state.preloadBySrc(fullSrc(e.target)) | |
if (is(e, '.ww-lb-thumb')) return state.preloadByIndex([...e.target.parentElement.children].indexOf(e.target)) | |
if (is(e, '.ww-lb-prev')) return state.preloadPrev() | |
if (is(e, '.ww-lb-next, .ww-lb-image')) return state.preloadNext() | |
}, {passive: true}) | |
document.addEventListener('keydown', (e) => { | |
if (e.key === 'ArrowLeft') state.prev() | |
if (e.key === 'ArrowRight') state.next() | |
if (e.key === 'Escape') state.hide() | |
}) | |
// Extra CSS | |
document.querySelector('head').appendChild(document.createRange().createContextualFragment(` | |
<style> | |
${IMAGE_SELECTOR} { | |
image-orientation: from-image; | |
} | |
</style> | |
`)) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment