1814 lines
50 KiB
JavaScript
1814 lines
50 KiB
JavaScript
//=====================================================================
|
|
//
|
|
//
|
|
//
|
|
// TODO:
|
|
// - crop selection
|
|
// - make the gallery into a web component
|
|
//
|
|
//
|
|
//=====================================================================
|
|
|
|
|
|
//---------------------------------------------------------------------
|
|
// 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: <top>,
|
|
// left: <left>,
|
|
// bottom: <bottom>,
|
|
// <right>: <right>,
|
|
// }
|
|
// XXX add support for:
|
|
// css selector
|
|
// element
|
|
bounds,
|
|
// can be:
|
|
// 'x'
|
|
// 'y'
|
|
// undefined
|
|
lock,
|
|
// start(<elem>, <data>)
|
|
// -> undefined
|
|
// -> <data>
|
|
start,
|
|
// can be:
|
|
// 'strict'
|
|
// 'scroll' / true
|
|
// false
|
|
keepInView = true,
|
|
// move(<elem>, <data>)
|
|
move = function(elem, data){
|
|
data.x != null
|
|
&& (elem.style.left = data.x + 'px')
|
|
data.y != null
|
|
&& (elem.style.top = data.y + 'px') },
|
|
// end(<elem>, <data>)
|
|
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
|
|
// <number> - 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: `
|
|
<div class="gallery">
|
|
<!-- gallery: content -->
|
|
<div class="images">
|
|
</div>
|
|
<!-- lightbox -->
|
|
<div class="lightbox">
|
|
<img>
|
|
<div class="button close"></div>
|
|
</div>
|
|
</div>`,
|
|
|
|
dom: undefined,
|
|
|
|
// XXX these are the same....
|
|
__toolbar: undefined,
|
|
get toolbar(){
|
|
if(this.dom){
|
|
return this.__toolbar
|
|
?? (this.__toolbar = {__proto__: Toolbar }
|
|
.setup(
|
|
this.dom.querySelector('.toolbar'),
|
|
this)) }
|
|
delete this.__toolbar
|
|
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')
|
|
// -> <all-images>
|
|
//
|
|
// .getImages('marked')
|
|
// -> <marked-images>
|
|
//
|
|
// .getImages(<img>, ..)
|
|
// .getImages([<img>, ..])
|
|
// -> <images>
|
|
//
|
|
// .getImages('sorted', ..)
|
|
// .getImages(.., 'sorted')
|
|
// -> <sorted-images>
|
|
//
|
|
// <img> ::=
|
|
// ImageElement
|
|
// | <index>
|
|
// | '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(<image>)
|
|
// .load(<images>)
|
|
// .load(<image>, <index>)
|
|
// .load(<images>, <index>)
|
|
//
|
|
// <images> ::=
|
|
// <image>
|
|
// | [ <image>, .. ]
|
|
// <image> ::=
|
|
// <url>
|
|
// | [ <url>, <caption>, .. ]
|
|
// | { url: <url>, caption: <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
|
|
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.toolbar
|
|
|
|
// 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 :
|