bigpicture
Version:
Lightweight image and video viewer, supports youtube / vimeo
747 lines (635 loc) • 22.8 kB
JavaScript
// BigPicture.js | license MIT | henrygd.me/bigpicture
// trigger element used to open popup
let el
// set to true after first interaction
let initialized
// container element holding html needed for script
let container
// currently active display element (image, video, youtube / vimeo iframe container)
let displayElement
// popup image element
let displayImage
// popup video element
let displayVideo
// popup audio element
let displayAudio
// container element to hold youtube / vimeo iframe
let iframeContainer
// iframe to hold youtube / vimeo player
let iframeSiteVid
// store requested image source
let imgSrc
// button that closes the container
let closeButton
// youtube / vimeo video id
let siteVidID
// keeps track of loading icon display state
let isLoading
// timeout to check video status while loading
let checkMediaTimeout
// loading icon element
let loadingIcon
// caption element
let caption
// caption content element
let captionText
// store caption content
let captionContent
// hide caption button element
let captionHideButton
// open state for container element
let isOpen
// gallery open state
let galleryOpen
// used during close animation to avoid triggering timeout twice
let isClosing
// array of prev viewed image urls to check if cached before showing loading icon
const imgCache = []
// store whether image requested is remote or local
let remoteImage
// store animation opening callbacks
let animationStart
let animationEnd
// store changeGalleryImage callback
let onChangeImage
// gallery left / right icons
let rightArrowBtn
let leftArrowBtn
// position of gallery
let galleryPosition
// hold active gallery els / image src
let galleryEls
// counter element
let galleryCounter
// store images in gallery that are being loaded
let preloadedImages = {}
// whether device supports touch events
let supportsTouch
// options object
let opts
// Save bytes in the minified version
const appendEl = 'appendChild'
const createEl = 'createElement'
const removeEl = 'removeChild'
export default (options) => {
// initialize called on initial open to create elements / style / event handlers
initialized || initialize(options)
// clear currently loading stuff
if (isLoading) {
clearTimeout(checkMediaTimeout)
removeContainer()
}
opts = options
// store video id if youtube / vimeo video is requested
siteVidID = options.ytSrc || options.vimeoSrc
// store optional callbacks
animationStart = options.animationStart
animationEnd = options.animationEnd
onChangeImage = options.onChangeImage
// set trigger element
el = options.el
// wipe existing remoteImage state
remoteImage = false
// set caption if provided
captionContent = el.getAttribute('data-caption')
if (options.gallery) {
makeGallery(options.gallery, options.position)
} else if (siteVidID || options.iframeSrc) {
// if vimeo, youtube, or iframe video
// toggleLoadingIcon(true)
displayElement = iframeContainer
createIframe()
} else if (options.imgSrc) {
// if remote image
remoteImage = true
imgSrc = options.imgSrc
!~imgCache.indexOf(imgSrc) && toggleLoadingIcon(true)
displayElement = displayImage
displayElement.src = imgSrc
} else if (options.audio) {
// if direct video link
toggleLoadingIcon(true)
displayElement = displayAudio
displayElement.src = options.audio
checkMedia('audio file')
} else if (options.vidSrc) {
// if direct video link
toggleLoadingIcon(true)
if (options.dimensions) {
changeCSS(displayVideo, `width:${options.dimensions[0]}px`)
}
makeVidSrc(options.vidSrc)
checkMedia('video')
} else {
// local image / background image already loaded on page
displayElement = displayImage
// get img source or element background image
displayElement.src =
el.tagName === 'IMG'
? el.src
: window
.getComputedStyle(el)
.backgroundImage.replace(/^url|[(|)|'|"]/g, '')
}
// add container to page
container[appendEl](displayElement)
document.body[appendEl](container)
return {
close,
opts,
updateDimensions,
display: displayElement,
next: () => updateGallery(1),
prev: () => updateGallery(-1),
}
}
// create all needed methods / store dom elements on first use
function initialize(options) {
let startX, isPinch
// return close button elements
function createCloseButton(className) {
const el = document[createEl]('button')
el.className = className
el.innerHTML =
'<svg viewBox="0 0 48 48"><path d="M28 24L47 5a3 3 0 1 0-4-4L24 20 5 1a3 3 0 1 0-4 4l19 19L1 43a3 3 0 1 0 4 4l19-19 19 19a3 3 0 0 0 4 0v-4L28 24z"/></svg>'
return el
}
function createArrowSymbol(direction, style) {
const el = document[createEl]('button')
el.className = 'bp-lr'
el.innerHTML =
'<svg viewBox="0 0 129 129" height="70" fill="#fff"><path d="M88.6 121.3c.8.8 1.8 1.2 2.9 1.2s2.1-.4 2.9-1.2a4.1 4.1 0 0 0 0-5.8l-51-51 51-51a4.1 4.1 0 0 0-5.8-5.8l-54 53.9a4.1 4.1 0 0 0 0 5.8l54 53.9z"/></svg>'
changeCSS(el, style)
el.onclick = (e) => {
e.stopPropagation()
updateGallery(direction)
}
return el
}
// add style - if you want to tweak, run through beautifier
const style = document[createEl]('STYLE')
const containerColor = (options && options.overlayColor) ? options.overlayColor : 'rgba(0,0,0,.7)'
style.innerHTML =
`#bp_caption,#bp_container{bottom:0;left:0;right:0;position:fixed;opacity:0}#bp_container>*,#bp_loader{position:absolute;right:0;z-index:10}#bp_container,#bp_caption,#bp_container svg{pointer-events:none}#bp_container{top:0;z-index:9999;background:${containerColor};opacity:0;transition:opacity .35s}#bp_loader{top:0;left:0;bottom:0;display:flex;align-items:center;cursor:wait;background:0;z-index:9}#bp_loader svg{width:50%;max-width:300px;max-height:50%;margin:auto;animation:bpturn 1s infinite linear}#bp_aud,#bp_container img,#bp_sv,#bp_vid{user-select:none;max-height:96%;max-width:96%;top:0;bottom:0;left:0;margin:auto;box-shadow:0 0 3em rgba(0,0,0,.4);z-index:-1}#bp_sv{background:#111}#bp_sv svg{width:66px}#bp_caption{font-size:.9em;padding:1.3em;background:rgba(15,15,15,.94);color:#fff;text-align:center;transition:opacity .3s}#bp_aud{width:650px;top:calc(50% - 20px);bottom:auto;box-shadow:none}#bp_count{left:0;right:auto;padding:14px;color:rgba(255,255,255,.7);font-size:22px;cursor:default}#bp_container button{position:absolute;border:0;outline:0;background:0;cursor:pointer;transition:all .1s}#bp_container>.bp-x{padding:0;height:41px;width:41px;border-radius:100%;top:8px;right:14px;opacity:.8;line-height:1}#bp_container>.bp-x:focus,#bp_container>.bp-x:hover{background:rgba(255,255,255,.2)}.bp-x svg,.bp-xc svg{display:inline;height:21px;width:20px;fill:#fff;vertical-align:top;}.bp-xc svg{width:16px}#bp_container .bp-xc{left:2%;bottom:100%;padding:9px 20px 7px;background:#d04444;border-radius:2px 2px 0 0;opacity:.85}#bp_container .bp-xc:focus,#bp_container .bp-xc:hover{opacity:1}.bp-lr{top:50%;top:calc(50% - 130px);padding:99px 0;width:6%;background:0;border:0;opacity:.4;transition:opacity .1s}.bp-lr:focus,.bp-lr:hover{opacity:.8}@keyframes bpf{50%{transform:translatex(15px)}100%{transform:none}}@keyframes bpl{50%{transform:translatex(-15px)}100%{transform:none}}@keyframes bpfl{0%{opacity:0;transform:translatex(70px)}100%{opacity:1;transform:none}}@keyframes bpfr{0%{opacity:0;transform:translatex(-70px)}100%{opacity:1;transform:none}}@keyframes bpfol{0%{opacity:1;transform:none}100%{opacity:0;transform:translatex(-70px)}}@keyframes bpfor{0%{opacity:1;transform:none}100%{opacity:0;transform:translatex(70px)}}@keyframes bpturn{0%{transform:none}100%{transform:rotate(360deg)}}@media (max-width:600px){.bp-lr{font-size:15vw}}`
document.head[appendEl](style)
// create container element
container = document[createEl]('DIV')
container.id = 'bp_container'
container.onclick = close
closeButton = createCloseButton('bp-x')
container[appendEl](closeButton)
// gallery touch listeners
if ('ontouchend' in window && window.visualViewport) {
supportsTouch = true
container.ontouchstart = ({ touches, changedTouches }) => {
isPinch = touches.length > 1
startX = changedTouches[0].pageX
}
container.ontouchend = ({ changedTouches }) => {
if (galleryOpen && !isPinch && window.visualViewport.scale <= 1) {
let distX = changedTouches[0].pageX - startX
// swipe right
distX < -30 && updateGallery(1)
// swipe left
distX > 30 && updateGallery(-1)
}
}
}
// create display image element
displayImage = document[createEl]('IMG')
// create display video element
displayVideo = document[createEl]('VIDEO')
displayVideo.id = 'bp_vid'
displayVideo.setAttribute('playsinline', true)
displayVideo.controls = true
displayVideo.loop = true
// create audio element
displayAudio = document[createEl]('audio')
displayAudio.id = 'bp_aud'
displayAudio.controls = true
displayAudio.loop = true
// create gallery counter
galleryCounter = document[createEl]('span')
galleryCounter.id = 'bp_count'
// create caption elements
caption = document[createEl]('DIV')
caption.id = 'bp_caption'
captionHideButton = createCloseButton('bp-xc')
captionHideButton.onclick = toggleCaption.bind(null, false)
caption[appendEl](captionHideButton)
captionText = document[createEl]('SPAN')
caption[appendEl](captionText)
container[appendEl](caption)
// left / right arrow icons
rightArrowBtn = createArrowSymbol(1, 'transform:scalex(-1)')
leftArrowBtn = createArrowSymbol(-1, 'left:0;right:auto')
// create loading icon element
loadingIcon = document[createEl]('DIV')
loadingIcon.id = 'bp_loader'
loadingIcon.innerHTML =
'<svg viewbox="0 0 32 32" fill="#fff" opacity=".8"><path d="M16 0a16 16 0 0 0 0 32 16 16 0 0 0 0-32m0 4a12 12 0 0 1 0 24 12 12 0 0 1 0-24" fill="#000" opacity=".5"/><path d="M16 0a16 16 0 0 1 16 16h-4A12 12 0 0 0 16 4z"/></svg>'
// create youtube / vimeo container
iframeContainer = document[createEl]('DIV')
iframeContainer.id = 'bp_sv'
// create iframe to hold youtube / vimeo player
iframeSiteVid = document[createEl]('IFRAME')
iframeSiteVid.setAttribute('allowfullscreen', true)
iframeSiteVid.allow = 'autoplay; fullscreen'
iframeSiteVid.onload = () => iframeContainer[removeEl](loadingIcon)
changeCSS(
iframeSiteVid,
'border:0;position:absolute;height:100%;width:100%;left:0;top:0'
)
iframeContainer[appendEl](iframeSiteVid)
// display image bindings for image load and error
displayImage.onload = open
displayImage.onerror = open.bind(null, 'image')
window.addEventListener('resize', () => {
// adjust loader position on window resize
galleryOpen || (isLoading && toggleLoadingIcon(true))
// adjust iframe dimensions
displayElement === iframeContainer && updateDimensions()
})
// close container on escape key press and arrow buttons for gallery
document.addEventListener('keyup', ({ keyCode }) => {
keyCode === 27 && isOpen && close()
if (galleryOpen) {
keyCode === 39 && updateGallery(1)
keyCode === 37 && updateGallery(-1)
keyCode === 38 && updateGallery(10)
keyCode === 40 && updateGallery(-10)
}
})
// prevent scrolling with arrow keys if gallery open
document.addEventListener('keydown', (e) => {
const usedKeys = [37, 38, 39, 40]
if (galleryOpen && ~usedKeys.indexOf(e.keyCode)) {
e.preventDefault()
}
})
// trap focus within conainer while open
document.addEventListener(
'focus',
(e) => {
if (isOpen && !container.contains(e.target)) {
e.stopPropagation()
closeButton.focus()
}
},
true
)
// all done
initialized = true
}
// return transform style to make full size display el match trigger el size
function getRect() {
const { top, left, width, height } = el.getBoundingClientRect()
const leftOffset = left - (container.clientWidth - width) / 2
const centerTop = top - (container.clientHeight - height) / 2
const scaleWidth = el.clientWidth / displayElement.clientWidth
const scaleHeight = el.clientHeight / displayElement.clientHeight
return `transform:translate3D(${leftOffset}px, ${centerTop}px, 0) scale3D(${scaleWidth}, ${scaleHeight}, 0)`
}
function makeVidSrc(source) {
if (Array.isArray(source)) {
displayElement = displayVideo.cloneNode()
source.forEach((src) => {
const source = document[createEl]('SOURCE')
source.src = src
source.type = `video/${src.match(/.(\w+)$/)[1]}`
displayElement[appendEl](source)
})
} else {
displayElement = displayVideo
displayElement.src = source
}
}
function makeGallery(gallery, position) {
let galleryAttribute = opts.galleryAttribute || 'data-bp'
if (Array.isArray(gallery)) {
// is array of images
galleryPosition = position || 0
galleryEls = gallery
captionContent = gallery[galleryPosition].caption
} else {
// is element selector or nodelist
galleryEls = [].slice.call(
typeof gallery === 'string'
? document.querySelectorAll(`${gallery} [${galleryAttribute}]`)
: gallery
)
// find initial gallery position
const elIndex = galleryEls.indexOf(el)
galleryPosition =
position === 0 || position ? position : elIndex !== -1 ? elIndex : 0
// make gallery object w/ els / src / caption
galleryEls = galleryEls.map((el) => ({
el,
src: el.getAttribute(galleryAttribute),
caption: el.getAttribute('data-caption'),
}))
}
// show loading icon if needed
remoteImage = true
// set initial src to imgSrc so it will be cached in open func
imgSrc = galleryEls[galleryPosition].src
!~imgCache.indexOf(imgSrc) && toggleLoadingIcon(true)
if (galleryEls.length > 1) {
// if length is greater than one, add gallery stuff
container[appendEl](galleryCounter)
galleryCounter.innerHTML = `${galleryPosition + 1}/${galleryEls.length}`
if (!supportsTouch) {
// add arrows if device doesn't support touch
container[appendEl](rightArrowBtn)
container[appendEl](leftArrowBtn)
}
} else {
// gallery is one, just show without clutter
galleryEls = false
}
displayElement = displayImage
// set initial image src
displayElement.src = imgSrc
}
function updateGallery(movement) {
const galleryLength = galleryEls.length - 1
// only allow one change at a time
if (isLoading) {
return
}
// return if requesting out of range image
const isEnd =
(movement > 0 && galleryPosition === galleryLength) ||
(movement < 0 && !galleryPosition)
if (isEnd) {
// if beginning or end of gallery, run end animation
if (!opts.loop) {
changeCSS(displayImage, '')
setTimeout(
changeCSS,
9,
displayImage,
`animation:${
movement > 0 ? 'bpl' : 'bpf'
} .3s;transition:transform .35s`
)
return
}
// if gallery is looped, adjust position to beginning / end
galleryPosition = movement > 0 ? -1 : galleryLength + 1
}
// normalize position
galleryPosition = Math.max(
0,
Math.min(galleryPosition + movement, galleryLength)
)
// load images before and after for quicker scrolling through pictures
;[galleryPosition - 1, galleryPosition, galleryPosition + 1].forEach(
(position) => {
// normalize position
position = Math.max(0, Math.min(position, galleryLength))
// cancel if image has already been preloaded
if (preloadedImages[position]) return
const src = galleryEls[position].src
// create image for preloadedImages
const img = document[createEl]('IMG')
img.addEventListener('load', addToImgCache.bind(null, src))
img.src = src
preloadedImages[position] = img
}
)
// if image is loaded, show it
if (preloadedImages[galleryPosition].complete) {
return changeGalleryImage(movement)
}
// if not, show loading icon and change when loaded
isLoading = true
changeCSS(loadingIcon, 'opacity:.4;')
container[appendEl](loadingIcon)
preloadedImages[galleryPosition].onload = () => {
galleryOpen && changeGalleryImage(movement)
}
// if error, store error object in el array
preloadedImages[galleryPosition].onerror = () => {
galleryEls[galleryPosition] = {
error: 'Error loading image',
}
galleryOpen && changeGalleryImage(movement)
}
}
function changeGalleryImage(movement) {
if (isLoading) {
container[removeEl](loadingIcon)
isLoading = false
}
const activeEl = galleryEls[galleryPosition]
if (activeEl.error) {
// show alert if error
alert(activeEl.error)
} else {
// add new image, animate images in and out w/ css animation
const oldimg = container.querySelector('img:last-of-type')
displayImage = displayElement = preloadedImages[galleryPosition]
changeCSS(
displayImage,
`animation:${
movement > 0 ? 'bpfl' : 'bpfr'
} .35s;transition:transform .35s`
)
changeCSS(oldimg, `animation:${movement > 0 ? 'bpfol' : 'bpfor'} .35s both`)
container[appendEl](displayImage)
// update el for closing animation
if (activeEl.el) {
el = activeEl.el
}
}
// update counter
galleryCounter.innerHTML = `${galleryPosition + 1}/${galleryEls.length}`
// show / hide caption
toggleCaption(galleryEls[galleryPosition].caption)
// execute onChangeImage callback
onChangeImage && onChangeImage([displayImage, galleryEls[galleryPosition]])
}
// create video iframe
function createIframe() {
let url
const prefix = 'https://'
const suffix = 'autoplay=1'
// create appropriate url
if (opts.ytSrc) {
url = `${prefix}www.youtube${
opts.ytNoCookie ? '-nocookie' : ''
}.com/embed/${siteVidID}?html5=1&rel=0&playsinline=1&${suffix}`
} else if (opts.vimeoSrc) {
url = `${prefix}player.vimeo.com/video/${siteVidID}?${suffix}`
} else if (opts.iframeSrc) {
url = opts.iframeSrc
}
// add loading spinner to iframe container
changeCSS(loadingIcon, '')
iframeContainer[appendEl](loadingIcon)
// set iframe src to url
iframeSiteVid.src = url
updateDimensions()
setTimeout(open, 9)
}
function updateDimensions() {
let height
let width
// handle height / width / aspect / max width for iframe
const windowHeight = window.innerHeight * 0.95
const windowWidth = window.innerWidth * 0.95
const windowAspect = windowHeight / windowWidth
const [dimensionWidth, dimensionHeight] = opts.dimensions || [1920, 1080]
const iframeAspect = dimensionHeight / dimensionWidth
if (iframeAspect > windowAspect) {
height = Math.min(dimensionHeight, windowHeight)
width = height / iframeAspect
} else {
width = Math.min(dimensionWidth, windowWidth)
height = width * iframeAspect
}
iframeContainer.style.cssText += `width:${width}px;height:${height}px;`
}
// timeout to check video status while loading
function checkMedia(errMsg) {
if (~[1, 4].indexOf(displayElement.readyState)) {
open()
// short timeout to to make sure controls show in safari 11
setTimeout(() => {
displayElement.play()
}, 99)
} else if (displayElement.error) {
open(errMsg)
} else {
checkMediaTimeout = setTimeout(checkMedia, 35, errMsg)
}
}
// hide / show loading icon
function toggleLoadingIcon(bool) {
// don't show loading icon if noLoader is specified
if (opts.noLoader) {
return
}
// bool is true if we want to show icon, false if we want to remove
// change style to match trigger element dimensions if we want to show
bool &&
changeCSS(
loadingIcon,
`top:${el.offsetTop}px;left:${el.offsetLeft}px;height:${el.clientHeight}px;width:${el.clientWidth}px`
)
// add or remove loader from DOM
el.parentElement[bool ? appendEl : removeEl](loadingIcon)
isLoading = bool
}
// hide & show caption
function toggleCaption(captionContent) {
if (captionContent) {
captionText.innerHTML = captionContent
}
changeCSS(
caption,
`opacity:${captionContent ? `1;pointer-events:auto` : '0'}`
)
}
function addToImgCache(url) {
!~imgCache.indexOf(url) && imgCache.push(url)
}
// animate open of image / video; display caption if needed
function open(err) {
// hide loading spinner
isLoading && toggleLoadingIcon()
// execute animationStart callback
animationStart && animationStart()
// check if we have an error string instead of normal event
if (typeof err === 'string') {
removeContainer()
return opts.onError
? opts.onError()
: alert(`Error: The requested ${err} could not be loaded.`)
}
// if remote image is loaded, add url to imgCache array
remoteImage && addToImgCache(imgSrc)
// transform displayEl to match trigger el
displayElement.style.cssText += getRect()
// fade in container
changeCSS(container, `opacity:1;pointer-events:auto`)
// set animationEnd callback to run after animation ends (cleared if container closed)
if (animationEnd) {
animationEnd = setTimeout(animationEnd, 410)
}
isOpen = true
galleryOpen = !!galleryEls
// enlarge displayEl, fade in caption if hasCaption
setTimeout(() => {
displayElement.style.cssText += 'transition:transform .35s;transform:none'
captionContent && setTimeout(toggleCaption, 250, captionContent)
}, 60)
}
// close active display element
function close(e) {
const target = e ? e.target : container
const clickEls = [
caption,
captionHideButton,
displayVideo,
displayAudio,
captionText,
leftArrowBtn,
rightArrowBtn,
loadingIcon,
]
// blur to hide close button focus style
target.blur()
// don't close if one of the clickEls was clicked or container is already closing
if (isClosing || ~clickEls.indexOf(target)) {
return
}
// animate closing
displayElement.style.cssText += getRect()
changeCSS(container, 'pointer-events:auto')
// timeout to remove els from dom; use variable to avoid calling more than once
setTimeout(removeContainer, 350)
// clear animationEnd timeout
clearTimeout(animationEnd)
isOpen = false
isClosing = true
}
// remove container / display element from the DOM
function removeContainer() {
// clear src of displayElement (or iframe if display el is iframe container)
// needs to be done before removing container in IE
let srcEl =
displayElement === iframeContainer ? iframeSiteVid : displayElement
srcEl.removeAttribute('src')
// remove container from DOM & clear inline style
document.body[removeEl](container)
container[removeEl](displayElement)
changeCSS(container, '')
changeCSS(displayElement, '')
// remove caption
toggleCaption(false)
if (galleryOpen) {
// remove all gallery stuff
const images = container.querySelectorAll('img')
for (let i = 0; i < images.length; i++) {
container[removeEl](images[i])
}
isLoading && container[removeEl](loadingIcon)
container[removeEl](galleryCounter)
galleryOpen = galleryEls = false
preloadedImages = {}
supportsTouch || container[removeEl](rightArrowBtn)
supportsTouch || container[removeEl](leftArrowBtn)
// in case displayimage changed, we need to update event listeners
displayImage.onload = open
displayImage.onerror = open.bind(null, 'image')
}
// run close callback
opts.onClose && opts.onClose()
isClosing = isLoading = false
}
// style helper functions
function changeCSS({ style }, newStyle) {
style.cssText = newStyle
}