gallery/grid-n-view.js
Alex A. Naanou 209202945a cleanup and tweaks...
Signed-off-by: Alex A. Naanou <alex.nanou@gmail.com>
2023-07-20 22:06:51 +03:00

548 lines
14 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){
var W = elems[0].parentElement.clientWidth - 2
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)
// next row...
} 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 = 1/r1 < r2
if(!expanded_row){
var r = r1
} else {
var r = r2
row.push(elem) }
// patch the row...
for(var e of row){
e.style.height = Math.floor(h * r) + 'px' }
// 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 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() },
// 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 = {
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){
for(var i of this.dom.querySelectorAll('.images img.current')){
i.classList.remove('current') }
img.classList.add('current')
img.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
}) },
// 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\//, '/') }) },
//*/
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 },
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???
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...
updateMarkers: function(){
// 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')
img.after(mark) } }
// clear deselected...
for(var mark of this.dom.querySelectorAll('.images img:not(.selected)+.mark')){
mark.remove() }
return this },
select: function(){
this.current?.classList.add('selected')
return this.updateMarkers() },
deselect: function(){
this.current?.classList.remove('selected')
return this.updateMarkers() },
toggleSelect: function(){
this.current?.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)
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
.addEventListener('click', function(evt){
var target = evt.target
if(target.tagName == 'IMG'
// skip images in lightbox...
&& target.parentElement === that.dom){
that.current = target
that.show() } })
// 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.dom.style.display == 'block' },
show: function(url){
this.url = url
?? (this.gallery.current
?? this.gallery.next().current
?? {}).src
// set caption...
this.dom.setAttribute('caption',
(this.gallery.current
?? this.gallery.next().current
?? {})
.getAttribute('caption')
?? '')
this.dom.style.display = 'block'
return this },
hide: function(){
this.dom.style.display = ''
return this },
toggle: function(){
return this.shown ?
this.hide()
: this.show() },
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){
// 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() })
// mouseofver...
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 :