gallery/grid-n-view.js
Alex A. Naanou b4c6dc6107 basic buttons now work...
Signed-off-by: Alex A. Naanou <alex.nanou@gmail.com>
2023-09-01 16:25:21 +03:00

1813 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 TEST...
__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 :