Skip to content

Instantly share code, notes, and snippets.

@Klaster1
Last active June 7, 2018 10:51
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save Klaster1/188723c7542a2c2510312227bfee2042 to your computer and use it in GitHub Desktop.
Weight Weenies lightbox
// ==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