626 lines
16 KiB
JavaScript
626 lines
16 KiB
JavaScript
//=====================================================================
|
|
//
|
|
//
|
|
//
|
|
// TODO:
|
|
// - selection
|
|
// - drag-n-drop
|
|
// - sort/move
|
|
// - crop selection
|
|
// - full screen
|
|
// - make the gallery into a web component
|
|
//
|
|
//
|
|
//=====================================================================
|
|
|
|
|
|
|
|
//---------------------------------------------------------------------
|
|
|
|
// XXX need to account for scrollbar -- add hysteresis???
|
|
var patchFlexRows =
|
|
function(elems, prevent_row_expansion=false){
|
|
// NOTE: -1 here is to compensate for rounding errors...
|
|
var W = elems[0].parentElement.clientWidth - 1
|
|
var w = 0
|
|
var h
|
|
var row = []
|
|
var top = elems[0].offsetTop
|
|
// NOTE: this will by design skip the last row.
|
|
for(var elem of elems){
|
|
elem.style.height = ''
|
|
elem.style.width = ''
|
|
h = h
|
|
?? elem.offsetHeight
|
|
top = top
|
|
?? elem.offsetTop
|
|
// collect row...
|
|
if(elem.offsetTop == top){
|
|
w += elem.offsetWidth
|
|
row.push(elem)
|
|
// row donw + prep for next...
|
|
} else {
|
|
// NOTE: we are checking which will require a lesser resize
|
|
// the current row or it with the next image...
|
|
var r1 = W / w
|
|
var r2 = W / (w + elem.offsetWidth)
|
|
var expanded_row =
|
|
prevent_row_expansion ?
|
|
false
|
|
: 1/r1 < r2
|
|
if(!expanded_row){
|
|
var r = r1
|
|
} else {
|
|
var r = r2
|
|
row.push(elem) }
|
|
// patch the row...
|
|
var nw = 0
|
|
for(var e of row){
|
|
e.style.height = (h * r) + 'px'
|
|
nw += e.offsetWidth }
|
|
// prep for next row...
|
|
if(!expanded_row){
|
|
w = elem.offsetWidth
|
|
h = elem.offsetHeight
|
|
top = elem.offsetTop
|
|
row = [elem]
|
|
} else {
|
|
w = 0
|
|
h = null
|
|
top = null
|
|
row = [] }}}}
|
|
|
|
var getScrollParent =
|
|
function(elem){
|
|
var parent = elem.parentElement
|
|
while(parent !== document.body
|
|
&& parent.scrollHeight > parent.clientHeight){
|
|
parent = elem.parentElement }
|
|
return parent }
|
|
|
|
// XXX also need to check if scrolled under something...
|
|
var isVisible =
|
|
function(elem) {
|
|
const rect = elem.getBoundingClientRect()
|
|
return rect.top >= 0
|
|
&& rect.left >= 0
|
|
&& rect.bottom <= (window.innerHeight
|
|
|| document.documentElement.clientHeight)
|
|
&& rect.right <= (window.innerWidth
|
|
|| document.documentElement.clientWidth) }
|
|
|
|
|
|
|
|
//---------------------------------------------------------------------
|
|
|
|
// XXX add shift+arrow to select...
|
|
// XXX add home/end, pageup/pagedown...
|
|
var keyboard = {
|
|
ArrowLeft: function(){
|
|
gallery.lightbox.shown ?
|
|
gallery.lightbox.prev()
|
|
: gallery.prev() },
|
|
ArrowRight: function(){
|
|
gallery.lightbox.shown ?
|
|
gallery.lightbox.next()
|
|
: gallery.next() },
|
|
ArrowUp: function(evt){
|
|
evt.preventDefault()
|
|
gallery.lightbox.shown
|
|
|| gallery.up() },
|
|
ArrowDown: function(evt){
|
|
evt.preventDefault()
|
|
gallery.lightbox.shown
|
|
|| gallery.down() },
|
|
Enter: function(){
|
|
gallery.lightbox.toggle() },
|
|
Escape: function(){
|
|
gallery.lightbox.shown ?
|
|
gallery.lightbox.hide()
|
|
// XXX should we remember which image was current and select
|
|
// it again when needed???
|
|
: gallery.deselect_current ?
|
|
(gallery.current = null)
|
|
: null },
|
|
// selection...
|
|
' ': function(evt){
|
|
gallery.current
|
|
&& evt.preventDefault()
|
|
gallery.toggleSelect() },
|
|
// XXX use key codes...
|
|
'a': function(evt){
|
|
evt.preventDefault()
|
|
if(evt.ctrlKey){
|
|
gallery.selectAll() } },
|
|
'd': function(evt){
|
|
evt.preventDefault()
|
|
if(evt.ctrlKey){
|
|
gallery.deselectAll() } },
|
|
'i': function(evt){
|
|
evt.preventDefault()
|
|
if(evt.ctrlKey){
|
|
gallery.selectInverse() } },
|
|
}
|
|
|
|
|
|
|
|
//---------------------------------------------------------------------
|
|
|
|
var Gallery = {
|
|
|
|
// options...
|
|
//
|
|
deselect_current: true,
|
|
loop_images: true,
|
|
allow_row_expansion: true,
|
|
click_to_select: true,
|
|
|
|
|
|
code: `
|
|
<div class="gallery">
|
|
<!-- gallery: content -->
|
|
<div class="images">
|
|
</div>
|
|
<!-- lightbox -->
|
|
<div class="lightbox">
|
|
<img>
|
|
<div class="button close"></div>
|
|
</div>
|
|
</div>`,
|
|
|
|
dom: undefined,
|
|
|
|
__lightbox: undefined,
|
|
get lightbox(){
|
|
if(this.dom){
|
|
return this.__lightbox
|
|
?? (this.__lightbox = { __proto__: Lightbox }
|
|
.setup(
|
|
this.dom.querySelector('.lightbox'),
|
|
this)) }
|
|
delete this.__lightbox
|
|
return undefined },
|
|
|
|
get current(){
|
|
return this.dom.querySelector('.images img.current') },
|
|
set current(img){
|
|
// unset...
|
|
if(img == null){
|
|
this.current?.classList.remove('current')
|
|
return }
|
|
// set...
|
|
for(var i of this.dom.querySelectorAll('.images img.current')){
|
|
i.classList.remove('current') }
|
|
img.classList.add('current')
|
|
// XXX add offsets from borders...
|
|
img.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
offset: 10,
|
|
}) },
|
|
|
|
// XXX should this be writable???
|
|
get images(){
|
|
return [...this.dom.querySelectorAll('.images img')] },
|
|
|
|
get urls(){
|
|
return this.images
|
|
.map(function(img){
|
|
// XXX not sure if we should remove the preview dir...
|
|
return img.src }) },
|
|
/*/
|
|
return img.src
|
|
// remove preview dir...
|
|
.replace(/\/[0-9]+px\//, '/') }) },
|
|
//*/
|
|
|
|
// XXX add .loop_images support...
|
|
getRow: function(img, direction='current'){
|
|
if(['above', 'current', 'below'].includes(img)){
|
|
direction = img
|
|
img = this.current }
|
|
// get above/below row...
|
|
// XXX these are wastefull...
|
|
if(direction == 'above'){
|
|
var row = this.getRow(img)
|
|
var e = row[0].previousElementSibling
|
|
while(e && e.tagName != 'IMG'){
|
|
e = e.previousElementSibling }
|
|
return e ?
|
|
this.getRow(e)
|
|
: this.getRow(this.images.at(-1))
|
|
} else if(direction == 'below'){
|
|
// special case: nothing selected...
|
|
if(img == null){
|
|
return this.getRow() }
|
|
var row = this.getRow(img)
|
|
var e = row.at(-1).nextElementSibling
|
|
while(e && e.tagName != 'IMG'){
|
|
e = e.nextElementSibling }
|
|
return e ?
|
|
this.getRow(e)
|
|
: this.getRow(this.images[0]) }
|
|
// get current row...
|
|
var cur = img
|
|
?? this.current
|
|
if(cur == null){
|
|
var scroll = getScrollParent(this.dom).scrollTop
|
|
var images = this.images
|
|
for(cur of images){
|
|
if(cur.offsetTop >= scroll){
|
|
break } } }
|
|
var top = cur.offsetTop
|
|
var row = []
|
|
var e = cur
|
|
while(e && e.offsetTop == top){
|
|
row.push(e)
|
|
e = e.nextElementSibling
|
|
while(e && e.tagName != 'IMG'){
|
|
e = e.nextElementSibling } }
|
|
e = cur
|
|
while(e && e.offsetTop == top){
|
|
e === cur
|
|
|| row.unshift(e)
|
|
e = e.previousElementSibling
|
|
while(e && e.tagName != 'IMG'){
|
|
e = e.previousElementSibling } }
|
|
return row },
|
|
// XXX add .loop_images support...
|
|
getImage: function(img, direction='current'){
|
|
if(['left', 'above', 'current', 'below', 'right'].includes(img)){
|
|
direction = img
|
|
img = null }
|
|
// current...
|
|
if(direction == 'current'){
|
|
return img
|
|
?? this.current
|
|
?? this.getRow(img)[0]
|
|
// above/below...
|
|
} else if(direction == 'above' || direction == 'below'){
|
|
var row = this.getRow(direction)
|
|
var cur = this.current
|
|
?? row[0]
|
|
var c = cur.offsetLeft + cur.offsetWidth/2
|
|
var target
|
|
var min
|
|
for(var img of row){
|
|
var n = img.offsetLeft + img.offsetWidth/2
|
|
var d = Math.abs(n - c)
|
|
min = min ?? d
|
|
if(d <= min){
|
|
min = d
|
|
target = img } }
|
|
// left/right...
|
|
} else {
|
|
var row = this.getRow(img)
|
|
var i = row.indexOf(
|
|
img
|
|
?? this.current
|
|
?? row[0])
|
|
i += direction == 'left' ?
|
|
-1
|
|
: +1
|
|
i = i < 0 ?
|
|
row.length-1
|
|
: i >= row.length-1 ?
|
|
0
|
|
: i
|
|
var target = row[i] }
|
|
return target },
|
|
|
|
// XXX cache image list???
|
|
// XXX add .loop_images support...
|
|
prev: function(){
|
|
var images = this.images
|
|
var i = this.current == null ?
|
|
images.length-1
|
|
: images.indexOf(this.current)-1
|
|
i = i < 0 ?
|
|
images.length-1
|
|
: i
|
|
this.current = images[i]
|
|
return this },
|
|
next: function(){
|
|
var images = this.images
|
|
var i = this.current == null ?
|
|
0
|
|
: images.indexOf(this.current)+1
|
|
i = i >= images.length ?
|
|
0
|
|
: i
|
|
this.current = images[i]
|
|
return this },
|
|
|
|
// navigate images visually...
|
|
left: function(){
|
|
var cur = this.current
|
|
var row = this.getRow(cur)
|
|
var i = row.indexOf(cur) - 1
|
|
this.current = row[i < 0 ?
|
|
row.length-1
|
|
: i]
|
|
return this },
|
|
right: function(){
|
|
var cur = this.current
|
|
var row = this.getRow(cur)
|
|
var i = row.indexOf(cur) + 1
|
|
this.current = row[i >= row.length ?
|
|
0
|
|
: i]
|
|
return this },
|
|
up: function(){
|
|
this.current = this.getImage('above')
|
|
return this },
|
|
down: function(){
|
|
this.current = this.getImage('below')
|
|
return this },
|
|
|
|
// selection...
|
|
get selected(){
|
|
return this.dom.querySelectorAll('.images img.selected') },
|
|
// NOTE: this is here because we can't use :before / :after directly
|
|
// on the img tag...
|
|
// XXX make this generic and use a .marks list...
|
|
updateMarkers: function(){
|
|
var that = this
|
|
// select...
|
|
for(var img of this.dom.querySelectorAll('.images img.selected')){
|
|
var mark = img.nextElementSibling
|
|
while(mark && mark.tagName != 'IMG' && !mark.classList.contains('mark')){
|
|
mark = img.nextElementSibling }
|
|
if(!mark || !mark.classList.contains('mark')){
|
|
mark = document.createElement('div')
|
|
mark.classList.add('selected', 'mark')
|
|
mark.addEventListener('click', function(evt){
|
|
evt.stopPropagation()
|
|
that.deselect(mark) })
|
|
img.after(mark) } }
|
|
// clear deselected...
|
|
for(var mark of this.dom.querySelectorAll('.images img:not(.selected)+.mark')){
|
|
mark.remove() }
|
|
// update lightbox...
|
|
this.lightbox.shown
|
|
&& this.lightbox.update()
|
|
return this },
|
|
select: function(img){
|
|
img = img ?? this.current
|
|
img?.classList.add('selected')
|
|
return this.updateMarkers() },
|
|
deselect: function(img){
|
|
img = img ?? this.current
|
|
img?.classList.remove('selected')
|
|
return this.updateMarkers() },
|
|
toggleSelect: function(img){
|
|
img = img ?? this.current
|
|
img?.classList.toggle('selected')
|
|
this.updateMarkers()
|
|
return this },
|
|
selectAll: function(){
|
|
for(var img of this.images){
|
|
img.classList.add('selected') }
|
|
return this.updateMarkers() },
|
|
deselectAll: function(){
|
|
for(var img of this.images){
|
|
img.classList.remove('selected') }
|
|
return this.updateMarkers() },
|
|
selectInverse: function(){
|
|
for(var img of this.images){
|
|
img.classList.toggle('selected') }
|
|
return this.updateMarkers() },
|
|
|
|
show: function(){
|
|
this.lightbox.show()
|
|
return this },
|
|
|
|
update: function(){
|
|
patchFlexRows(this.images, !this.allow_row_expansion)
|
|
return this },
|
|
|
|
load: function(urls){
|
|
this.clear()
|
|
var images = this.dom.querySelector('.images')
|
|
for(var url of urls){
|
|
var img = document.createElement('img')
|
|
img.src = url
|
|
images.appendChild(img) }
|
|
return this },
|
|
clear: function(){
|
|
this.dom.querySelector('.images').innerHTML = ''
|
|
return this },
|
|
|
|
setup: function(dom){
|
|
var that = this
|
|
this.dom = dom
|
|
|
|
this.dom.querySelector('.images')
|
|
.addEventListener('click', function(evt){
|
|
evt.stopPropagation()
|
|
var target = evt.target
|
|
if(target.tagName == 'IMG'){
|
|
// shift+click: toggle selections...
|
|
if(evt.shiftKey){
|
|
that.toggleSelect(target)
|
|
// first click selects, second shows...
|
|
} else if(that.click_to_select){
|
|
target.classList.contains('current') ?
|
|
that.show()
|
|
: (that.current = target)
|
|
// first click selects and shows...
|
|
} else {
|
|
that.current = target
|
|
that.show() }
|
|
} else if(that.deselect_current){
|
|
that.current = null } })
|
|
this.dom
|
|
.addEventListener('click', function(evt){
|
|
that.deselect_current
|
|
&& (that.current = null) })
|
|
|
|
// handle resizing...
|
|
new ResizeObserver(
|
|
function(elems){
|
|
that.update() })
|
|
.observe(this.dom)
|
|
|
|
return this
|
|
.update() },
|
|
}
|
|
|
|
|
|
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
// XXX ignore click from blur...
|
|
// XXX might be a good idea to close on click outside the image...
|
|
// XXX esc from context menu closes view...
|
|
var Lightbox = {
|
|
dom: undefined,
|
|
gallery: undefined,
|
|
|
|
navigation_deadzone: 100,
|
|
caption_hysteresis: 10,
|
|
cache_count: 1,
|
|
|
|
get url(){
|
|
return this.dom.querySelector('img').src },
|
|
set url(url){
|
|
this.dom.querySelector('img').src = url
|
|
// remove preview dir...
|
|
.replace(/\/[0-9]+px\//, '/')
|
|
// cache...
|
|
this.cache_count != 0
|
|
&& this.cache() },
|
|
|
|
get shown(){
|
|
return this.gallery.dom.classList.contains('lightboxed') },
|
|
show: function(url){
|
|
this.url = url
|
|
?? (this.gallery.current
|
|
?? this.gallery.next().current
|
|
?? {}).src
|
|
this.update()
|
|
this.gallery.dom.classList.add('lightboxed')
|
|
return this },
|
|
hide: function(){
|
|
this.gallery.dom.classList.remove('lightboxed')
|
|
return this },
|
|
toggle: function(){
|
|
return this.shown ?
|
|
this.hide()
|
|
: this.show() },
|
|
|
|
update: function(){
|
|
// set caption...
|
|
this.dom.setAttribute('caption',
|
|
(this.gallery.current
|
|
?? this.gallery.next().current
|
|
?? {})
|
|
.getAttribute('caption')
|
|
?? '')
|
|
// set selection...
|
|
this.gallery.current.classList.contains('selected') ?
|
|
this.dom.classList.add('selected')
|
|
: this.dom.classList.remove('selected')
|
|
return this },
|
|
|
|
prev: function(){
|
|
this.gallery.prev().show()
|
|
return this },
|
|
next: function(){
|
|
this.gallery.next().show()
|
|
return this },
|
|
|
|
__cache: undefined,
|
|
cache: function(){
|
|
var cache = []
|
|
var _cache = this.__cache = []
|
|
var cur = this.gallery.current
|
|
var images = [...this.gallery.dom.querySelectorAll('img')].slice(1)
|
|
var i = images.indexOf(cur)
|
|
var c = this.cache_count ?? 2
|
|
for(var j=i+1; j<=i+c; j++){
|
|
cache.push(j >= images.length ?
|
|
j % images.length
|
|
: j) }
|
|
for(var j=i-1; j>=i-c; j--){
|
|
cache.unshift(j < 0 ?
|
|
images.length+j
|
|
: j) }
|
|
for(i of cache){
|
|
var img = document.createElement('img')
|
|
img.src = images[i].src
|
|
.replace(/\/[0-9]+px\//, '/')
|
|
_cache.push(img) }
|
|
return this },
|
|
|
|
setup: function(dom, gallery){
|
|
var that = this
|
|
this.dom = dom
|
|
this.gallery = gallery
|
|
// controls...
|
|
this.dom.querySelector('.close')
|
|
.addEventListener('click', function(evt){
|
|
evt.stopPropagation()
|
|
that.hide() })
|
|
// click...
|
|
var deadzone = this.navigation_deadzone ?? 100
|
|
this.dom
|
|
.addEventListener('click', function(evt){
|
|
evt.stopPropagation()
|
|
// click left/right side of view...
|
|
// NOTE: this is vewport-relative...
|
|
evt.clientX < that.dom.offsetWidth / 2 - deadzone/2
|
|
&& that.prev()
|
|
evt.clientX > that.dom.offsetWidth / 2 + deadzone/2
|
|
&& that.next() })
|
|
// mousemove...
|
|
var hysteresis = this.caption_hysteresis ?? 10
|
|
this.dom
|
|
.addEventListener('mousemove', function(evt){
|
|
// indicate action...
|
|
if(evt.clientX < that.dom.offsetWidth / 2 - deadzone/2){
|
|
that.dom.classList.contains('clickable')
|
|
|| that.dom.classList.add('clickable')
|
|
} else if( evt.clientX > that.dom.offsetWidth / 2 + deadzone/2){
|
|
that.dom.classList.contains('clickable')
|
|
|| that.dom.classList.add('clickable')
|
|
} else {
|
|
that.dom.classList.contains('clickable')
|
|
&& that.dom.classList.remove('clickable') }
|
|
// show/hide caption...
|
|
// hysteresis:
|
|
// +---+-- off
|
|
// | |
|
|
// v ^
|
|
// | |
|
|
// on -+---+
|
|
evt.clientY > that.dom.offsetHeight / 2 + hysteresis
|
|
&& that.dom.classList.add('show-caption')
|
|
evt.clientY < that.dom.offsetHeight / 2 - hysteresis
|
|
&& that.dom.classList.remove('show-caption') })
|
|
return this },
|
|
}
|
|
|
|
|
|
|
|
//---------------------------------------------------------------------
|
|
|
|
var setupGallery = function(gallery){
|
|
return {__proto__: Gallery}
|
|
.setup(gallery) }
|
|
|
|
var setup = function(){
|
|
var galleries = document.body.querySelectorAll('.gallery')
|
|
for(var gallery of galleries){
|
|
// XXX this is wrong...
|
|
window.gallery = setupGallery(gallery) }
|
|
// keyboard...
|
|
document.addEventListener('keydown', function(evt){
|
|
var key = evt.key
|
|
if(key in keyboard){
|
|
keyboard[key](evt) } }) }
|
|
|
|
|
|
|
|
//---------------------------------------------------------------------
|
|
// vim:set ts=4 sw=4 :
|