//===================================================================== // // // // TODO: // - drag-n-drop // - sort/move // - crop selection // - make the gallery into a web component // // //===================================================================== //--------------------------------------------------------------------- // This compansates for any resize rounding errors in patchFlexRows(..). var PATCH_MARGIN = 2 var patchFlexRows = function(elems, prevent_row_expansion=false, last_row_resize=1.5){ if(elems.length == 0){ return } // NOTE: -1 here is to compensate for rounding errors... var W = elems[0].parentElement.clientWidth - PATCH_MARGIN var w = 0 var h var row = [] var top = elems[0].offsetTop // modes: // 'as-is' | false // 'expand' // 'close' // closure: W, w, h, row, elem var max = 0 var handleRow = function(mode='justify'){ if(!mode || mode == 'as-is'){ return false } if(typeof(mode) == 'number'){ var r = Math.min( W / w, max * mode) } else { var r = W / w } var expanded if(mode == 'expand'){ var r2 = W / (w + elem.offsetWidth) // NOTE: we are checking which will require a lesser resize // the current row or it with the next image... if(1/r < r2){ expanded = true var r = r2 row.push(elem) } } max = Math.max(max, r) // patch the row... var nw = 0 for(var e of row){ if(r == 0 || h == 0){ e.style.height = '' } else { e.style.height = (h * r) + 'px' } nw += e.offsetWidth } return !!expanded } // NOTE: this will by design skip the last row... // ...because we handle the row only when we see an image at a // different vertical offset... 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 done + prep for next... } else { var expanded_row = prevent_row_expansion ? handleRow() : handleRow('expand') // 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 = [] }}} // handle last row... last_row_resize && handleRow(last_row_resize) } 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... // XXX need a real ui stack -- close things top to bottom (Enter/Escape/...)... var keyboard = { ArrowLeft: function(){ gallery.prev() }, ArrowRight: function(){ 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.dom.classList.contains('lightboxed') || gallery.dom.classList.contains('detailed')) && evt.preventDefault() gallery.lightbox.shown || gallery.details.shown || gallery.up() }, ArrowDown: function(evt){ gallery.__at_bottom_row || evt.preventDefault() ;(gallery.dom.classList.contains('lightboxed') || gallery.dom.classList.contains('detailed')) && evt.preventDefault() gallery.lightbox.shown || gallery.details.shown || gallery.down() }, Enter: function(){ gallery.details.shown ? gallery.details.hide() : gallery.lightbox.toggle() }, Escape: function(evt){ gallery.details.shown ? gallery.details.hide() : gallery.lightbox.shown ? gallery.lightbox.hide() // XXX should we remember which image was current and select // it again when needed??? : gallery.unmark_current ? (gallery.current = null) : null }, // selection... ' ': function(evt){ gallery.current && evt.preventDefault() gallery.toggleMark() }, // XXX use key codes... 'a': function(evt){ evt.preventDefault() if(evt.ctrlKey){ gallery.selectAll() } }, 'd': function(evt){ evt.preventDefault() if(evt.ctrlKey){ gallery.unmarkAll() } }, 'i': function(evt){ evt.preventDefault() if(evt.ctrlKey){ gallery.markInverse() } }, } //--------------------------------------------------------------------- var Gallery = { // Options... // unmark_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 auto-disable this when paging is required. loop_images: false, // If true for each row two versions will be compared, as-is and with // one image from the next row and the closest resiae will be chosen. // // This produses a more uniform image grid but will have greater // effect on gallery height / slower. allow_row_expansion: true, // define how to handle last row // // can be: // 'as-is' - do not resize // 'justify' - fit width // - maximum ration relative to largest row (must be >=1) // // Recomended value close to 1 last_row_resize: 1, // If true first click on image will select it the second click will // open it... 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 }, __details: undefined, get details(){ if(this.dom){ return this.__details ?? (this.__details = { __proto__: Details } .setup( this.dom.querySelector('.details'), this)) } delete this.__details 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 marked(){ return [...this.dom.querySelectorAll('.images img.marked')] }, get length(){ return this.images.length }, get index(){ return this.images.indexOf(this.current) }, getRow: function(img, direction='current', images){ if(['above', 'current', 'below'].includes(img)){ images = direction direction = img img = this.current } if(direction instanceof Array){ images = direction direction = null } direction ??= 'current' images ??= this.images // 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(images.at(-1)) : undefined } else if(direction == 'below'){ // special case: nothing marked... 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(images[0]) : undefined } // get current row... var cur = img ?? this.current if(cur == null){ var scroll = getScrollParent(this.dom).scrollTop var images = 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', images){ // .getImage(direction[, images]) if(['left', 'above', 'current', 'below', 'right'].includes(img)){ images = direction direction = img img = null } // .getImage(img, images) if(direction instanceof Array){ images = direction direction = null } direction ??= 'current' images ??= this.images // current... if(direction == 'current'){ return img ?? this.current ?? this.getRow(img, images)[0] // above/below... // get image with closest center to target image center... } else if(direction == 'above' || direction == 'below'){ var row = this.getRow(direction, images) 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, images) 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(images){ 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] this.update() return this }, next: function(images){ 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] this.update() return this }, // navigate images visually... // XXX BUG: these seem not to work with passed list of images... left: function(images){ var cur = this.current var row = this.getRow(cur, images) var i = row.indexOf(cur) - 1 this.current = row[i < 0 ? row.length-1 : i] return this }, right: function(images){ var cur = this.current var row = this.getRow(cur, images) var i = row.indexOf(cur) + 1 this.current = row[i >= row.length ? 0 : i] return this }, up: function(images){ var img = this.getImage('above', images) img && (this.current = img) return this }, down: function(images){ var img = this.getImage('below', images) img && (this.current = img) return this }, // selection... // // 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.marked')){ 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('marked', 'mark') mark.addEventListener('click', function(evt){ evt.stopPropagation() that.unmark(mark) }) img.after(mark) } } // clear unmarked... for(var mark of this.dom.querySelectorAll('.images img:not(.marked)+.mark')){ mark.remove() } // update lightbox... this.lightbox.shown && this.lightbox.update() return this }, mark: function(img){ img = img ?? this.current img?.classList.add('marked') return this.updateMarkers() }, unmark: function(img){ img = img ?? this.current img?.classList.remove('marked') return this.updateMarkers() }, toggleMark: function(img){ img = img ?? this.current img?.classList.toggle('marked') this.updateMarkers() return this }, selectAll: function(){ for(var img of this.images){ img.classList.add('marked') } return this.updateMarkers() }, unmarkAll: function(){ for(var img of this.images){ img.classList.remove('marked') } return this.updateMarkers() }, markInverse: function(){ for(var img of this.images){ img.classList.toggle('marked') } return this.updateMarkers() }, show: function(){ return this.showLightbox() }, showLightbox: function(){ this.lightbox.show() return this }, showDetails: function(){ this.details.show() return this }, __update_grid_size_timeout: undefined, __update_grid_size_running: false, __update_grid_size: function(){ // if still running then delay untill done... // NOTE: this will keep only one call no matter how many times // the method was actually called... if(this.__update_grid_size_running){ var that = this this.__update_grid_size_timeout && clearTimeout(this.__update_grid_size_timeout) this.__update_grid_size_timeout = setTimeout(function(){ that.__update_grid_size() delete that.__update_grid_size_timeout }, 10) return this } // do the update... this.__update_grid_size_running = true try{ patchFlexRows(this.images, !this.allow_row_expansion, this.last_row_resize ?? 1.2) }catch(err){ } delete this.__update_grid_size_running return this }, update: function(){ this.__update_grid_size() this.lightbox.shown && this.lightbox.update() this.details.shown && this.details.update() return this }, // .load() // .load() // .load(, ) // .load(, ) // // ::= // // | [ , .. ] // ::= // // | [ , , .. ] // | { url: , caption: , .. } // // XXX BUG: for some reason this breaks the gallery: // gallery.load(gallery.json('marked')) // XXX do we handle previews here??? load: function(images, index=undefined){ var that = this images = images instanceof Array ? images : [images] // create images... var elems = [] for(var data of images){ if(typeof(data) == 'string'){ var [url, data] = [data, {}] } else if(data instanceof Array){ var [url, caption] = data data = {} caption ?? (data.caption = caption) } else { var {url, ...data} = data } var elem = document.createElement('img') elem.onload = function(){ that.update() } elem.src = url elem.setAttribute('draggable', 'true') for(var [key, value] of Object.entries(data)){ value // XXX is this a good way to destinguish classes and attrs??? && (typeof(value) == 'boolean' ? elem.classList.add(key) : 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 .updateMarkers() .update() }, __image_attributes__: [ 'caption', ], __image_classes__: [ // XXX should this be here or set as a root attribute??? 'current', 'marked', ], // XXX do we handle previews here??? json: function(images=undefined){ var that = this images = (images == 'all' || images == '*') ? this.images : images == 'marked' ? this.marked : !images ? this.images : images return images .map(function(img, i){ var res = { url: img.src } for(var key of that.__image_attributes__ ?? []){ var value = img.getAttribute(key) value && (res[key] = value) } for(var key of that.__image_classes__ ?? []){ img.classList.contains(key) && (res[key] = true) } 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.toggleMark(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.unmark_current){ that.current = null } }) this.dom .addEventListener('click', function(evt){ that.unmark_current && (that.current = null) }) // drag/drop... // XXX add a local drag mode... this.dom .addEventListener('dragover', function(evt){ evt.preventDefault() evt.stopPropagation() evt.dataTransfer.dropEffect = 'copy' }, false) this.dom .addEventListener('drop', function(evt){ evt.preventDefault() evt.stopPropagation() var files = evt.dataTransfer.items ? [...evt.dataTransfer.items] .map(function(elem){ return elem.kind == 'file' ? [elem.getAsFile()] : [] }) .flat() : [...evt.dataTransfer.files] Promise.all(files .map(function(file){ if(!file.type.match('image.*')){ return [] } if(file.path){ return file.path } else { return new Promise(function(resolve, reject){ var reader = new FileReader() reader.onload = function(f){ resolve(f.target.result) } reader.readAsDataURL(file) }) } }) .flat()) .then( function(images){ // no images... if(images.length == 0){ return } return that.load(images) }, function(err){ // XXX handle errors... }) }, false) // XXX for(var img of this.images){ img.setAttribute('draggable', 'true') } // handle resizing... new ResizeObserver( function(elems){ that.__update_grid_size() }) .observe(this.dom) return this .updateMarkers() .update() }, } //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // This is a prototype for all modal views... var Overlay = { dom: undefined, gallery: undefined, cls: 'overlay', 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 && this.cache_count != 0 && this.cache() }, get shown(){ return this.gallery.dom.classList.contains(this.cls) }, show: function(){ this.update() this.gallery.dom.classList.add(this.cls) return this }, hide: function(){ this.gallery.exit_fullscreen_on_lightbox_close && document.fullscreenElement && document.exitFullscreen() this.gallery.dom.classList.remove(this.cls) return this }, toggle: function(){ return this.shown ? this.hide() : this.show() }, cache: null, update: function(){ this.url = (this.gallery.current ?? this.gallery.next().current ?? {}).src 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 ?? '${CAPTION}') .replace(/\${CAPTION}/, caption) .replace(/\${INDEX}/, index)) // set selection... this.gallery.current.classList.contains('marked') ? this.dom.classList.add('marked') : this.dom.classList.remove('marked') 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() }) this.dom.querySelector('.info') ?.addEventListener('click', function(evt){ evt.stopPropagation() that.gallery.showDetails() }) this.dom.querySelector('.prev') ?.addEventListener('click', function(evt){ evt.stopPropagation() that.gallery.prev() }) this.dom.querySelector('.next') ?.addEventListener('click', function(evt){ evt.stopPropagation() that.gallery.next() }) // stop body from scrolling... var stop = function(evt){ evt.stopPropagation() evt.preventDefault() return false } this.dom.addEventListener('touchmove', stop) this.dom.addEventListener('mousewheel', stop) this.dom.addEventListener('wheel', stop) this.dom.addEventListener('scroll', stop) // drag... this.dom .addEventListener('dragover', function(evt){ that.gallery.dom.scrollIntoView({ behavior: 'smooth', block: 'nearest', }) that.hide() }) return this }, } //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var Lightbox = { __proto__: Overlay, cls: 'lightboxed', caption_format: '${INDEX} ${CAPTION}', navigation_deadzone: 100, caption_hysteresis: 10, cache_count: 1, __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 Overlay.setup.call(this, ...arguments) // click zones... 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.gallery.prev() evt.clientX > that.dom.offsetWidth / 2 + deadzone/2 && that.gallery.next() }) // hover zones... 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 Details = { __proto__: Overlay, cls: 'detailed', // XXX } //--------------------------------------------------------------------- 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 -- handle multiple galleries... 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 :