//===================================================================== // // // // 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){ if(elems.length == 0){ return } // 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() }, // NOTE: up/down will not prevent the default scrolling behavior // when at top/bottom of the gallery. ArrowUp: function(evt){ gallery.__at_top_row || evt.preventDefault() gallery.lightbox.shown || gallery.up() }, ArrowDown: function(evt){ gallery.__at_bottom_row || evt.preventDefault() gallery.lightbox.shown || gallery.down() }, Enter: function(){ gallery.lightbox.toggle() }, Escape: function(evt){ 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, // If true navigation will loop over top/bottom rows and first/last // images... // This is mainly usefull for small galleries that do not need paging. // XXX might be a good idea to autodisable this when paging is required. loop_images: false, allow_row_expansion: true, click_to_select: true, exit_fullscreen_on_lightbox_close: true, // Mode to select the above/below image... // // // - -----+-------------+ // | . | // | current | // | . | // - --+-------+---.---+--.--+ // | . | . | // | B | A | // | . | . | // - --+---------------+-----+ // ^ ^ ^ // c i c // // Here, A has the closest center (c) to current but B has the closest // center of intersection (i), thus the two approaches will yield // different results, moving down from current: // current ----(center)----> A // current -(intersection)-> B // // can be: // 'intersection' - closest center of intersecting part to center // of current image. // 'center' - closest center of image to current image center // XXX remove this when/if the selected options feels natural... vertical_navigate_mode: 'intersection', code: ` `, 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 }, __at_top_row: undefined, __at_bottom_row: 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', }) // helpers... this.__at_top_row = !this.getRow('above') this.__at_bottom_row = !this.getRow('below') }, // 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\//, '/') }) }, //*/ get length(){ return this.images.length }, get index(){ return this.images.indexOf(this.current) }, 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.loop_images ? this.getRow(this.images.at(-1)) : undefined } 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.loop_images ? this.getRow(this.images[0]) : undefined } // 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... // get image with closest center to target image center... } else if(direction == 'above' || direction == 'below'){ var row = this.getRow(direction) if(row == null){ return undefined } var cur = this.current ?? row[0] // image center point... var c = cur.offsetLeft + cur.offsetWidth/2 var target var min for(var img of row){ // length of intersection... if(this.vertical_navigate_mode == 'intersection'){ var l = Math.max( img.offsetLeft, cur.offsetLeft) var r = Math.min( img.offsetLeft + img.offsetWidth, cur.offsetLeft + cur.offsetWidth) var w = r - l var n = l + w/2 // closest center... } else { 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 ? i : this.loop_images ? images.length-1 : 0 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 ? i : this.loop_images ? 0 : images.length-1 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(){ var img = this.getImage('above') img && (this.current = img) return this }, down: function(){ var img = this.getImage('below') img && (this.current = img) 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() // .load() // .load(, ) // .load(, ) // // ::= // // | [ , .. ] // ::= // // | [ , , .. ] // | { url: , caption: , .. } // // XXX do we handle previews here??? load: function(images, index=undefined){ images = images instanceof Array ? images : [images] // create images... var elems = [] for(var data of images){ if(typeof(data) == 'string'){ var [url] = [data] } else if(data instanceof Array){ var [url, ...data] = data } else { var {url, ...data} = data } var elem = document.createElement('img') elem.src = url elem.setAttribute('draggable', 'true') for(var [key, value] of Object.entries(data)){ value && elem.setAttribute(key, value) } elems.push(elem) } // add to gallery... if(index == null){ this.clear() } if(index == null || this.length > 0){ this.dom.querySelector('.images') .append(...elems) } else { var sibling = this.images.at(index) index < 0 ? sibling.after(...elems) : sibling.before(...elems) } return this .update() }, __image_attributes__: [ 'caption', ], // XXX do we handle previews here??? json: function(){ var that = this return this.images .map(function(img){ var res = { url: img.src } for(var key of that.__image_attributes__){ var value = img.getAttribute(key) value && (res[key] = value) } return res }) }, remove: function(...images){ if(images.includes('all')){ return this.clear() } // NOTE: we need to remove images from the end so as not to be // affected by shifed indexes... images .sort() .reverse() for(var img of images){ typeof(img) == 'number' ? this.images.at(img)?.remove() : img instanceof Element ? (this.images.contains(img) && img.remove()) : null } return this .update() }, 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) }) // drag... this.dom .addEventListener('dragover', function(evt){ // XXX }) this.dom .addEventListener('drop', function(evt){ evt.preventDefault() // XXX }) // XXX for(var img of this.images){ img.setAttribute('draggable', 'true') } // 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, caption_format: '${INDEX} ${CAPTION}', 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.exit_fullscreen_on_lightbox_close && document.fullscreenElement && document.exitFullscreen() this.gallery.dom.classList.remove('lightboxed') return this }, toggle: function(){ return this.shown ? this.hide() : this.show() }, update: function(){ var caption = (this.gallery.current ?? this.gallery.next().current ?? {}) .getAttribute('caption') ?? '' var index = (this.gallery.index+1) +'/'+ this.gallery.length // set caption... // XXX should these be separate elements??? this.dom.setAttribute('caption', this.caption_format .replace(/\${CAPTION}/, caption) .replace(/\${INDEX}/, index)) // 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() }) this.dom.querySelector('.fullscreen') .addEventListener('click', function(evt){ evt.stopPropagation() document.fullscreenElement ? document.exitFullscreen() : that.dom.requestFullscreen() }) // drag... this.dom .addEventListener('dragover', function(evt){ that.gallery.dom.scrollIntoView({ behavior: 'smooth', block: 'nearest', }) 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 :