//===================================================================== // // // // TODO: // - ui for cropping... // - drag-n-drop for touch devices... // // //===================================================================== //--------------------------------------------------------------------- // Generic stuff... // 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, patch_margin=PATCH_MARGIN){ 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) } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var _ruler var px2rem = function(px){ if(!_ruler){ _ruler = document.createElement('div') document.body.append(_ruler) } _ruler.style.width = '1rem' var c = _ruler.offsetWidth return px / c } var rem2px = function(rem){ if(!_ruler){ _ruler = document.createElement('div') document.body.append(_ruler) } _ruler.style.width = rem + 'em' var px = _ruler.offsetWidth return px } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var getTouch = function(evt, id){ if(id != null && id !== false && evt.targetTouches){ for(var k in evt.targetTouches){ if(evt.targetTouches[k]?.identifier == id){ return evt.targetTouches[k] } } } } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // XXX constrain this to: // viewport -- DONE but needs to be smoother... // parent // element // ...might be a good idea to write a fast/generic containment // tester... // XXX might be a good idea to allow bounds to be: // string - css selector // element - // XXX might be a good idea to either: // - scroll into view the dragged element // - bound it by screen // XXX need to check if element already set as movable... // XXX does it make sence to abstract out the bounds checking code??? // XXX docs... HTMLElement.prototype.moveable = function(options={}){ var elem = this if(elem.dataset.movable){ throw new Error('element already movable.') } elem.dataset.movable = true // options... var { // CSS class added to element while it is dragged... cls = 'movable', // Drag handle element -- watches the drag events... handle = elem, // Blement bounding box used to check bounds... box = elem, // Bounds object... // can be: // false // { // top: , // left: , // bottom: , // : , // } // XXX add support for: // css selector // element bounds, // can be: // 'x' // 'y' // undefined lock, // start(, ) // -> undefined // -> start, // can be: // 'strict' // 'scroll' / true // false keepInView = true, // move(, ) move = function(elem, data){ data.x != null && (elem.style.left = data.x + 'px') data.y != null && (elem.style.top = data.y + 'px') }, // end(, ) end, ignoreBounds = false, } = options handle = typeof(handle) == 'string' ? elem.querySelector(handle) : handle var data var handleMoveStart = function(evt){ evt.preventDefault() evt.stopPropagation() if(!data){ cls && elem.classList.add(cls) var x = evt.clientX ?? evt.targetTouches[0].clientX var y = evt.clientY ?? evt.targetTouches[0].clientY data = { bounds: bounds, offset: { x: x - elem.offsetLeft, y: y - elem.offsetTop, }, touch: evt.targetTouches ? evt.targetTouches[0].identifier : undefined, } if(typeof(start) == 'function'){ var res = start(elem, data) data = res != null ? res : data } } } var handleMove = function(evt){ if(data){ var src = data.touch != null ? getTouch(evt, data.touch) : evt if(!src){ return } evt.preventDefault() evt.stopPropagation() // viewport bounding box in elem coordinates... var x = src.clientX var y = src.clientY data.x = lock == 'x' ? null : (!ignoreBounds && data.bounds != null) ? Math.min( data.bounds.right, Math.max( data.bounds.left, x - data.offset.x)) : x - data.offset.x data.y = lock == 'y' ? null : (!ignoreBounds && data.bounds != null) ? Math.min( data.bounds.bottom, Math.max( data.bounds.top, y - data.offset.y)) : y - data.offset.y // restrict to viewport... if(!ignoreBounds && keepInView == 'strict'){ var t, l var bb = elem.getBoundingClientRect() var screen = { top: t = box.offsetTop - bb.top, left: l = box.offsetLeft - bb.left, bottom: window.innerHeight + t - box.offsetHeight, right: window.innerWidth + l - box.offsetWidth, } data.x = Math.min( screen.right, Math.max( screen.left, data.x)) data.y = Math.min( screen.bottom, Math.max( screen.top, data.y)) } // NOTE: we only allow a single requestAnimationFrame(..) // to run per frame... if(!data.animate){ data.animate = requestAnimationFrame(function(){ if(!data){ return } move && move(elem, data) // keep in view... // NOTE: this works best with CSS's scroll-margin... !ignoreBounds && (keepInView == 'scroll' || keepInView === true) && elem.scrollIntoView({ block: 'nearest' }) delete data.animate }) } } } var handleMoveEnd = function(evt){ if(data){ if(evt.targetTouches && (evt.targetTouches.length == 0 || getTouch(evt, data.touch))){ return } evt.preventDefault() evt.stopPropagation() cls && elem.classList.remove(cls) end && end(elem, data) data = false } } // XXX can we reuse these??? // ...i.e. keep the data in the element??? handle.addEventListener('mousedown', handleMoveStart) handle.addEventListener('touchstart', handleMoveStart) document.addEventListener('mousemove', handleMove) document.addEventListener('touchmove', handleMove) document.addEventListener('touchend', handleMoveEnd) document.addEventListener('mouseup', handleMoveEnd) } //--------------------------------------------------------------------- // 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 }, // del (image) -> mark current for deletion // del (marked) -> toggle marked for deletion all marked // shift-del (none marked for deletion) -> delete current // shift-del (1+ marked for deletion) -> delete marked for deletion Delete: function(evt){ // remove... if(evt.shiftKey){ var to_remove = gallery.toBeRemoved // remove marked... if(to_remove.length > 0){ gallery.removeQueued() // remove current... } else if(gallery.current){ to_remove = gallery.current if(gallery.marked.includes(to_remove)){ gallery.remove(...gallery.marked) } else { // move focus... gallery.next() gallery.current === to_remove && gallery.prev() gallery.remove(to_remove) } } // mark for removal... } else if(gallery.current){ var cur = gallery.current // toggle marked... if(gallery.marked.includes(cur)){ gallery.toggleQueueRemoval('marked') // toggle current... } else { gallery.toggleQueueRemoval() } } }, // selection... ' ': function(evt){ gallery.current && evt.preventDefault() gallery.toggleMark() }, // XXX use key codes... 'a': function(evt){ evt.preventDefault() if(evt.ctrlKey){ gallery.markAll() } }, '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', // a timeout to let the resize settle before we handle dragover... // XXX this is too long -- we can pickup and drag an image within this // timeout... // need to make it more specific (handle only vertical drag???) resize_settle_timeout: 16, code: ` `, dom: undefined, // XXX these are the same.... // XXX add support for multiple toolbars... __toolbars: undefined, get toolbars(){ if(this.dom){ var that = this return this.__toolbars ?? (this.__toolbars = [...this.dom.querySelectorAll('.toolbar')] .map(function(toolbar){ return {__proto__: Toolbar} .setup( toolbar, that) })) } delete this.__toolbars return 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){ return img.src }) }, 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 }, // // .getImages() // -> [] // // .getImages('*') // .getImages('all') // -> // // .getImages('marked') // -> // // .getImages(, ..) // .getImages([, ..]) // -> // // .getImages('sorted', ..) // .getImages(.., 'sorted') // -> // // ::= // ImageElement // | // | 'current' // getImages: function(...images){ var that = this var sorted = images.at(-1) == 'sorted' ? !!images.pop() : images[0] == 'sorted' ? !!images.shift() : false images = images.flat() if(images.includes('all') || images.includes('*')){ return this.images } images = [...new Set(images .map(function(img){ return ((typeof(img) == 'string' && img.trim() == '') ? [] : !isNaN(img*1) ? that.images.at(img*1) : img == 'marked' ? that.marked : img == 'current' ? that.current : typeof(img) == 'string' ? [] : img) ?? [] }) .flat())] if(sorted){ var order = new Map( Object.entries(this.images) .map(function(e){ return e.reverse() })) images .sort(function(a, b){ return order.get(a) - order.get(b) }) } return images }, // 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 }, markAll: 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() }, // XXX should this be an interface to something like .splash??? get loading(){ return !this.dom.classList.contains('ready') }, showLoading: function(){ this.dom.classList.remove('ready') return this }, hideLoading: function(){ this.dom.classList.add('ready') return this }, toggleLoading: function(){ this.dom.classList.toggle('ready') return this }, // show current image in lightbox... show: function(){ this.lightbox.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() // XXX should this update markers??? //this.updateMarkers() 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', 'filename', ], __image_classes__: [ // XXX should this be here or set as a root attribute??? 'current', 'marked', ], // XXX add option to include images as data urls... // XXX do we handle previews here??? json: function(images=undefined){ var that = this return this.getImages(images ?? this.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 }) }, // XXX add support for .load(..)... editorJson: function(){ var data = { varsion: 1, gallery: this.json(), } // XXX get serilization handlers... var handlers = [] for(var handler of handlers){ handler.call(this, data) } return data }, // XXX zip: function(){ var json = this.json() // XXX }, // removal queue... get toBeRemoved(){ return [...gallery.dom.querySelectorAll('.images img.to-remove')] }, set toBeRemoved(lst){ this.queueRemoval(lst) }, // XXX should this be writable... get toBeKeept(){ return [...gallery.dom.querySelectorAll('.images img:not(.to-remove)')] }, set toBeKeept(lst){ this.unqueueRemoval(lst) }, queueRemoval: function(...images){ images = images.length == 0 ? ['current'] : images for(var img of this.getImages(...images)){ img.classList.add('to-remove') } return this }, unqueueRemoval: function(...images){ images = images.length == 0 ? ['current'] : images for(var img of this.getImages(...images)){ img.classList.remove('to-remove') } return this }, toggleQueueRemoval: function(...images){ images = images.length == 0 ? ['current'] : images for(var img of this.getImages(...images)){ img.classList.toggle('to-remove') } return this }, removeQueued: function(){ return this.remove(...this.toBeRemoved) }, remove: function(...images){ if(images.includes('all') || images.includes('*')){ return this.clear() } // NOTE: we need to remove images from the end so as not to be // affected by shifed indexes... images = this.getImages('sorted', ...images) .reverse() for(var img of images){ typeof(img) == 'number' ? this.images.at(img)?.remove() : img instanceof Element ? (this.images.includes(img) && img.remove()) : null } return this .updateMarkers() .update() }, clear: function(){ this.dom.querySelector('.images').innerHTML = '' return this }, // crop... __crop_stack: undefined, crop: function(lst){ var stack = this.__crop_stack ??= [] var state = this.json() stack.push(state) if(lst){ state = this.json(this.getImages(lst)) } this.load(state) return this }, uncrop: function(lst){ var state = this.__crop_stack?.pop() state && state.length == 0 && (delete this.__crop_stack) state && this.load(state) return this }, // named crops... // XXX add these to serilization... // XXX add default loaded crop... // XXX might be fun to explore using crops as history... __saved_crops: undefined, get savedCropNames(){ return Object.keys(this.__saved_crops ?? {}) }, saveCrop: function(name){ var saved = this.__saved_crops ??= {} saved[name] = this.json() return this }, loadCrop: function(name){ var state = (this.__saved_crops ?? {})[name] state && this.load(state) return this }, removeCrop: function(name){ delete (this.__saved_crops ?? {})[name] return this }, setup: function(dom){ var that = this this.dom = dom this.toolbars // image clicks... 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: sort... var dragged var dragged_over this.dom .addEventListener('dragstart', function(evt){ var i = that.images.indexOf(evt.target) if(i >= 0){ dragged = evt.target dragged.classList.add('dragged') evt.dataTransfer.setData('text/uri-list', evt.target.src) evt.dataTransfer.setData('text/plain', evt.target.src) // XXX this is ugly in FF... //evt.dataTransfer.setDragImage(dragged, evt.offsetX, evt.offsetY) // XXX add } }) var skip_dragover = false this.dom .addEventListener('dragenter', function(evt){ evt.preventDefault() evt.stopPropagation() // NOTE: this prevents jumping back and forth if moving // image causes a resize that causes the image to // move again... if(skip_dragover){ return } var target = evt.target var i = that.images.indexOf(target) if(dragged && i >= 0){ evt.offsetX < target.offsetWidth / 2 ? target.after(dragged) : target.before(dragged) // skip a dragover event if triggered by (right // after) resize... skip_dragover = true setTimeout(function(){ skip_dragover = false }, that.resize_settle_timeout) that .__update_grid_size() .updateMarkers() dragged.scrollIntoView({ behavior: 'smooth', block: 'nearest', }) } }) // check if we just went out of the edge image... // NOTE: this handles a special case: // |[A][ B ][C] | // when a narrow image (A, C) is at the edge and the // adjacent image is wide (b). // dragging the narrow image over the wide places it at // the other side of the wide image but the cursor is now // over the wide image so to drag back we either need to // drag out of it and drag over again (not intuitive) or // simply drag over the oppoiste edge of the wide image // (dragleave handler) // NOTE: we are not implementing the whole drag process here // because dragging up/down here is far more complicated // than when doing it in dragover... // XXX might be a good idea when dragged is null (dragging in files) // to place a placeholder between images instead of styling // the image below... this.dom .addEventListener('dragleave', function(evt){ // cleanup on drag out... // XXX this does not work sometimes... if(evt.target === that.dom){ that.dom.classList.remove('dragged-over') dragged_over && dragged_over.classList.remove('dragged-over') } // check edge... if(!dragged || skip_dragover){ return } var target = evt.target var images = that.images var i = images.indexOf(target) if(dragged !== target && i >= 0){ // left edge... if(evt.offsetX <= 0){ var prev = images[i-1] // adjacent image is not on the same offsetTop (edge) if(prev == null || prev.offsetTop != target.offsetTop){ target.efore(dragged) that.updateMarkers() } // right edge... } else { var next = images[i+1] // adjacent image is not on the same offsetTop (edge) if(next == null || next.offsetTop != target.offsetTop){ target.after(dragged) that.updateMarkers() } } } }) this.dom .addEventListener('dragover', function(evt){ evt.preventDefault() evt.stopPropagation() evt.dataTransfer.dropEffect = 'copy' var target = evt.target var i = that.images.indexOf(target) if(!dragged && i >= 0){ // XXX BUG: canceling drag leaves the classes in place... // ...handling dragend does not help... // indicate replacing all... if(!evt.shiftKey){ that.dom.classList.add('dragged-over') target.classList.remove('dragged-over') // indicate insertion... } else { that.dom.classList.remove('dragged-over') dragged_over && dragged_over.classList.remove('dragged-over') dragged_over = target dragged_over.classList.add('dragged-over') } } }, false) // drag/drop: files... // NOTE: if shift is pressed then this will add files to the // loaded list, otherwise it will replace the list... // XXX handle serilized data... this.dom .addEventListener('drop', function(evt){ evt.preventDefault() evt.stopPropagation() // non-local drag... if(!dragged){ that.showLoading() var expand = (evt.shiftKey && dragged_over) ? that.images.indexOf(dragged_over) : undefined 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){ // XXX handle serilized data... // XXX // images... if(file.type.match('image.*')){ // XXX TEST... if(file.path){ return { url: file.path, filename: file.name, } } else { return new Promise(function(resolve, reject){ var reader = new FileReader() reader.onload = function(f){ resolve({ url: f.target.result, filename: file.name, // XXX any other metadata to include??? }) } reader.readAsDataURL(file) }) } } // other files... return [] }) .flat()) .then( function(images){ that.hideLoading() // no images... if(images.length == 0){ return } return that.load(images, expand) }, function(err){ // XXX handle errors... that.hideLoading() }) } dragged && dragged.classList.remove('dragged') dragged_over && dragged_over.classList.remove('dragged-over') that.dom.classList.remove('dragged-over') // XXX if this is used in the promise, move to the point // after we nned this... dragged = undefined dragged_over = undefined }, false) // drag/drom: cleanup... // XXX BUG: this does not handle the situation when drag was cancelled // but while the browser was not focused leaving the class // on the element... // to reproduce: // - start drag of files // - drag over the gallery // - press esc to cancel drag // ...this will leave the dragged-over classes in place... var cleanupAfterDrag = function(evt){ that.dom.classList.remove('dragged-over') dragged_over && dragged_over.classList.remove('dragged-over') } //this.dom.addEventListener('dragend', cleanupAfterDrag) // XXX HACK-ish... document.body.addEventListener('dragenter', cleanupAfterDrag) // XXX HACK... this.dom.addEventListener('mouseover', cleanupAfterDrag) // 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() .hideLoading() }, } //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var ContextProto = { dom: undefined, parent: undefined, constructors: {}, end: function(){ return this.parent }, setup: function(){ return this }, } var Context = function(name, constructors, extend={}){ var obj = this instanceof Context ? obj : Reflect.construct( Function, ['return this.setup ? this.setup(...arguments) : this'], Context) return obj } var Toolbar = { dom: undefined, gallery: undefined, cls: 'toolbar', // time to hold the handle to toggle autohide... hold: 300, // autohide timeout... toolbar_autohide: 2000, // XXX make these generic... // XXX rename .toggle(..) to something like .toggleExpanded(..) toggle: function(action='toggle'){ var toolbar = this.dom?.classList toolbar && (action == 'toggle' ? toolbar.toggle('shown') : action == 'on' ? toolbar.add('shown') : toolbar.remove('shown')) return this }, movable: function(action='toggle'){ var toolbar = this.dom?.classList toolbar && (action == 'toggle' ? toolbar.toggle('fixed') : action == 'off' ? toolbar.add('fixed') : toolbar.remove('fixed')) return this }, get shown(){ return this.dom.style.display != 'none' }, show: function(){ // XXX this.dom.style.display = '' return this }, hide: function(){ this.dom.style.display = 'none' return this }, setup: function(dom, gallery){ var that = this this.dom = dom this.gallery = gallery // toolbar... var toolbar = this.dom var toolbar_moving = false // prevent clicks in toolbar from affecting the gallery... toolbar .addEventListener('click', function(evt){ evt.stopPropagation() }) // toolbar: collapse: click, hold to make sticky... var hold_timeout var holding_toggle var handleInteractionStart = function(evt){ holding_toggle = true hold_timeout = setTimeout( function(){ hold_timeout = undefined toolbar.classList.toggle('sticky') }, that.hold ?? 300) } var handleInteractionEnd = function(evt){ if(holding_toggle){ holding_toggle = false evt.preventDefault() if(hold_timeout){ clearTimeout(hold_timeout) hold_timeout = undefined that.toggle() } }} var collapse_button = toolbar.querySelector('.collapse') collapse_button.addEventListener('touchstart', handleInteractionStart) collapse_button.addEventListener('mousedown', handleInteractionStart) collapse_button.addEventListener('touchend', handleInteractionEnd) collapse_button.addEventListener('mouseup', handleInteractionEnd) // toolbar: autohide... var hide_timeout var toolbarAutoHideTimerStart = function(evt){ if(that.autohide == 0 || toolbar.classList.contains('sticky')){ return } hide_timeout = setTimeout( function(){ hide_timeout = undefined that.toggle('hide') }, that.autohide ?? 1000) } var toolbarAutoHideCancel = function(evt){ hide_timeout && clearTimeout(hide_timeout) hide_timeout = undefined } toolbar.addEventListener('mouseout', toolbarAutoHideTimerStart) toolbar.addEventListener('touchend', toolbarAutoHideTimerStart) toolbar.addEventListener('mouseover', toolbarAutoHideCancel) toolbar.addEventListener('touchstart', toolbarAutoHideCancel) // toolbar: move... // XXX to drag anywhere on the elem we need to prevent // clicks while dragging... toolbar.moveable({ cls: 'moving', handle: '.drag-handle', // set bounds... start: function(elem, data){ if(elem.classList.contains('fixed')){ return false } data.bounds = { top: 0, left: 0, right: that.gallery.dom.offsetWidth - elem.offsetWidth - 20, bottom: that.gallery.dom.offsetHeight - elem.offsetHeight - 20, } }, //keepInView: 'scroll', keepInView: 'strict', move: function(elem, data){ data.x != null && elem.style.setProperty('--move-x', data.x + 'px') data.y != null && elem.style.setProperty('--move-y', data.y + 'px') }, }) // keep toolbar in view while scrolling... // NOTE: top is taken care of by position: sticky... window.addEventListener('scroll', function(evt){ var bb = toolbar.getBoundingClientRect() if(window.innerHeight <= bb.bottom){ var top = toolbar.offsetTop - bb.top var bottom = window.innerHeight + top - toolbar.offsetHeight toolbar.style.setProperty('--move-y', bottom + 'px') } }) return this }, } //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // 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() }, __clicks_canceled: false, __clicks_canceled_timeout: 500, get shown(){ return this.gallery.dom.classList.contains(this.cls) }, show: function(){ var that = this this.__clicks_canceled = true setTimeout( function(){ that.__clicks_canceled = false }, this.__clicks_canceled_timeout ?? 200) 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() if(that.__clicks_canceled){ return } that.hide() }) this.dom.querySelector('.fullscreen') ?.addEventListener('click', function(evt){ evt.stopPropagation() if(that.__clicks_canceled){ return } document.fullscreenElement ? document.exitFullscreen() : that.dom.requestFullscreen() }) this.dom.querySelector('.info') ?.addEventListener('click', function(evt){ evt.stopPropagation() if(that.__clicks_canceled){ return } that.gallery.details.show() }) this.dom.querySelector('.prev') ?.addEventListener('click', function(evt){ evt.stopPropagation() if(that.__clicks_canceled){ return } that.gallery.prev() }) this.dom.querySelector('.next') ?.addEventListener('click', function(evt){ evt.stopPropagation() if(that.__clicks_canceled){ return } 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: 0.3, 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 dblclick_canceled = false var deadzone = this.navigation_deadzone ?? 0.3 this.dom .addEventListener('click', function(evt){ evt.stopPropagation() if(that.__clicks_canceled){ return } var d = that.dom.offsetWidth * deadzone // click left/right side of view... // NOTE: this is vewport-relative... dblclick_canceled = false evt.clientX < that.dom.offsetWidth / 2 - d/2 && that.gallery.prev() && (dblclick_canceled = true) evt.clientX > that.dom.offsetWidth / 2 + d/2 && that.gallery.next() && (dblclick_canceled = true) }) this.dom .addEventListener('dblclick', function(evt){ evt.stopPropagation() dblclick_canceled || that.__clicks_canceled || that.hide() }) // hover zones... var hysteresis = this.caption_hysteresis ?? 10 this.dom .addEventListener('mousemove', function(evt){ var d = that.dom.offsetWidth * deadzone // indicate action... if(evt.clientX < that.dom.offsetWidth / 2 - d/2){ that.dom.classList.contains('clickable') || that.dom.classList.add('clickable') } else if( evt.clientX > that.dom.offsetWidth / 2 + d/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){ if(window.gallery.loading){ return } var key = evt.key if(key in keyboard){ keyboard[key](evt) } }) } //--------------------------------------------------------------------- // vim:set ts=4 sw=4 :