diff --git a/css/grid-n-view.css b/css/grid-n-view.css
new file mode 100644
index 0000000..10bce25
--- /dev/null
+++ b/css/grid-n-view.css
@@ -0,0 +1,133 @@
+/**********************************************************************
+*
+*
+*
+**********************************************************************/
+
+
+/********************************************************* Config ****/
+
+:root {
+ /* dimensions */
+ --gallery-scrollbar-width: 0.5em;
+
+ --lightbox-frame-size: 5vmin;
+ --lightbox-image-margin-top: 0.75;
+ --lightbox-button-size: 4em;
+
+ /* theme */
+ --gallery-text-color: black;
+ --gallery-secondary-color: silver;
+ --gallery-background-color: white;
+
+ --lightbox-background-color: white;
+}
+
+
+
+/****************************************************** Scrolling ****/
+
+::-webkit-scrollbar {
+ width: var(--gallery-scrollbar-width);
+}
+::-webkit-scrollbar-track {
+ background-color: transparent;
+ border-radius: 100px;
+}
+::-webkit-scrollbar-thumb {
+ background-color: var(--gallery-secondary-color);
+ border-radius: 100px;
+}
+body {
+ overflow-y: scroll;
+}
+
+
+/******************************************************** Gallery ****/
+
+/* XXX need to account for scrollbar popping in and out */
+.gallery {
+ position: relative;
+ display: flex;
+ justify-content: flex-start;
+ align-content: flex-start;
+ flex-flow: row wrap;
+
+ margin-left: var(--gallery-scrollbar-width);
+ margin-right: 0;
+}
+.gallery img {
+ height: 300px;
+ width: auto;
+ image-rendering: crisp-edges;
+ box-sizing: border-box;
+}
+.gallery>img {
+ cursor: hand;
+}
+.gallery img.current {
+ border: solid 2px red;
+}
+
+
+/******************************************************* Lightbox ****/
+
+.gallery .lightbox {
+ display: none;
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ top: 0px;
+ left: 0px;
+
+ text-align: center;
+
+ background: var(--lightbox-background-color);
+}
+.gallery .lightbox.show-caption:after {
+ content: attr(caption);
+ position: absolute;
+ bottom: 0.5em;
+ left: 0.5em;
+}
+.gallery .lightbox.clickable {
+ cursor: hand;
+}
+/* XXX add metadata display... */
+.gallery .lightbox img {
+ object-fit: contain;
+ width: calc(
+ 100vw
+ - var(--lightbox-frame-size) * 2);
+ height: calc(
+ 100vh
+ - var(--lightbox-frame-size) * 2);
+ margin-top: calc(
+ var(--lightbox-frame-size)
+ * var(--lightbox-image-margin-top));
+}
+/* controls: next/prev... */
+.lightbox .button {
+ cursor: hand;
+ font-size: var(--lightbox-button-size);
+ padding: 0 0.25em;
+ filter: saturate(0);
+ opacity: 0.1;
+}
+.lightbox .button:hover {
+ opacity: 1;
+ filter: saturate(1);
+}
+/* controls: close... */
+.gallery .lightbox .button.close {
+ position: absolute;
+ top: 0px;
+ right: 0px;
+}
+.gallery .lightbox .button.close:after {
+ content: "×";
+ color: red;
+}
+
+
+/*********************************************************************/
diff --git a/grid-n-view.html b/grid-n-view.html
index 4c53aad..5f9db71 100644
--- a/grid-n-view.html
+++ b/grid-n-view.html
@@ -1,571 +1,18 @@
+
-
+
+
+
Grid n' View
+
+
+
+
+
+
diff --git a/grid-n-view.js b/grid-n-view.js
new file mode 100644
index 0000000..f2f8a1a
--- /dev/null
+++ b/grid-n-view.js
@@ -0,0 +1,439 @@
+//---------------------------------------------------------------------
+
+// XXX need to account for scrollbar -- add hysteresis???
+var patchFlexRows =
+function(elems){
+ var W = elems[0].parentElement.clientWidth - 2
+ var w = 0
+ var h
+ var row = []
+ var top = elems[0].offsetTop
+ // NOTE: this will by design skip the last row.
+ for(var elem of elems){
+ elem.style.height = ''
+ elem.style.width = ''
+ h = h
+ ?? elem.offsetHeight
+ top = top
+ ?? elem.offsetTop
+ // collect row...
+ if(elem.offsetTop == top){
+ w += elem.offsetWidth
+ row.push(elem)
+ // next row...
+ } else {
+ // NOTE: we are checking which will require a lesser resize
+ // the current row or it with the next image...
+ var r1 = W / w
+ var r2 = W / (w + elem.offsetWidth)
+ var expanded_row = 1/r1 < r2
+ if(!expanded_row){
+ var r = r1
+ } else {
+ var r = r2
+ row.push(elem) }
+ // patch the row...
+ for(var e of row){
+ e.style.height = Math.floor(h * r) + 'px' }
+ // prep for next row...
+ if(!expanded_row){
+ w = elem.offsetWidth
+ h = elem.offsetHeight
+ top = elem.offsetTop
+ row = [elem]
+ } else {
+ w = 0
+ h = null
+ top = null
+ row = [] }}}}
+
+var getScrollParent =
+function(elem){
+ var parent = elem.parentElement
+ while(parent !== document.body
+ && parent.scrollHeight > parent.clientHeight){
+ parent = elem.parentElement }
+ return parent }
+
+// XXX also need to check if scrolled under something...
+var isVisible =
+function(elem) {
+ const rect = elem.getBoundingClientRect()
+ return rect.top >= 0
+ && rect.left >= 0
+ && rect.bottom <= (window.innerHeight
+ || document.documentElement.clientHeight)
+ && rect.right <= (window.innerWidth
+ || document.documentElement.clientWidth) }
+
+
+
+//---------------------------------------------------------------------
+
+// XXX add home/end, pageup/pagedown...
+var keyboard = {
+ ArrowLeft: function(){
+ gallery.lightbox.shown ?
+ gallery.lightbox.prev()
+ : gallery.prev() },
+ ArrowRight: function(){
+ gallery.lightbox.shown ?
+ gallery.lightbox.next()
+ : gallery.next() },
+ ArrowUp: function(evt){
+ evt.preventDefault()
+ gallery.lightbox.shown
+ || gallery.up() },
+ ArrowDown: function(evt){
+ evt.preventDefault()
+ gallery.lightbox.shown
+ || gallery.down() },
+ Enter: function(){
+ gallery.lightbox.toggle() },
+ Escape: function(){
+ gallery.lightbox.shown
+ && gallery.lightbox.hide() },
+}
+
+
+
+//---------------------------------------------------------------------
+
+var Gallery = {
+ dom: undefined,
+
+ __lightbox: undefined,
+ get lightbox(){
+ if(this.dom){
+ return this.__lightbox
+ ?? (this.__lightbox = { __proto__: Lightbox }
+ .setup(
+ this.dom.querySelector('.lightbox'),
+ this)) }
+ delete this.__lightbox
+ return undefined },
+
+ get current(){
+ return this.dom.querySelector('img.current') },
+ set current(img){
+ for(var i of this.dom.querySelectorAll('img.current')){
+ i.classList.remove('current') }
+ img.classList.add('current')
+ img.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ }) },
+
+ getRow: function(img, direction='current'){
+ if(['above', 'current', 'below'].includes(img)){
+ direction = img
+ img = null }
+ // get above/below row...
+ // XXX these are wastefull...
+ if(direction == 'above'){
+ var row = this.getRow(img)
+ var e = row[0].previousSibling
+ while(e && e.tagName != 'IMG'){
+ e = e.previousSibling }
+ return e ?
+ this.getRow(e)
+ : this.getRow([...this.dom.querySelectorAll('img')].at(-1))
+ } else if(direction == 'below'){
+ var row = this.getRow(img)
+ var e = row.at(-1).nextSibling
+ while(e && e.tagName != 'IMG'){
+ e = e.nextSibling }
+ return e ?
+ this.getRow(e)
+ : this.getRow([...this.dom.querySelectorAll('img')][1]) }
+ // get current row...
+ var cur = img
+ ?? this.current
+ if(cur == null){
+ var scroll = getScrollParent(this.dom).scrollTop
+ var images = [...this.dom.querySelectorAll('img')].slice(1)
+ 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.nextSibling
+ while(e && e.tagName != 'IMG'){
+ e = e.nextSibling } }
+ e = cur
+ while(e && e.offsetTop == top){
+ e === cur
+ || row.unshift(e)
+ e = e.previousSibling
+ while(e && e.tagName != 'IMG'){
+ e = e.previousSibling } }
+ return row },
+ getImage: function(img, direction='current'){
+ if(['left', 'above', 'current', 'below', 'right'].includes(img)){
+ direction = img
+ img = null }
+ // current...
+ if(direction == 'current'){
+ return img
+ ?? this.current
+ ?? this.getRow(img)
+ // above/below...
+ } else if(direction == 'above' || direction == 'below'){
+ var row = this.getRow(direction)
+ var cur = this.current
+ ?? row[0]
+ var c = cur.offsetLeft + cur.offsetWidth/2
+ var target
+ var min
+ for(var img of row){
+ var n = img.offsetLeft + img.offsetWidth/2
+ var d = Math.abs(n - c)
+ min = min ?? d
+ if(d <= min){
+ min = d
+ target = img } }
+ // left/right...
+ } else {
+ var row = this.getRow(img)
+ var i = row.indexOf(
+ img
+ ?? this.current
+ ?? row[0])
+ i += direction == 'left' ?
+ -1
+ : +1
+ i = i < 0 ?
+ row.length-1
+ : i >= row.length-1 ?
+ 0
+ : i
+ var target = row[i] }
+ return target },
+
+ // XXX cache image list???
+ prev: function(){
+ var images = [...this.dom.querySelectorAll('img')].slice(1)
+ var i = this.current == null ?
+ images.length-1
+ : images.indexOf(this.current)-1
+ i = i < 0 ?
+ images.length-1
+ : i
+ this.current = images[i]
+ return this },
+ next: function(){
+ var images = [...this.dom.querySelectorAll('img')].slice(1)
+ var i = this.current == null ?
+ 0
+ : images.indexOf(this.current)+1
+ i = i >= images.length ?
+ 0
+ : i
+ this.current = images[i]
+ return this },
+
+ // navigate images visually...
+ left: function(){
+ var cur = this.current
+ var row = this.getRow(cur)
+ var i = row.indexOf(cur) - 1
+ this.current = row[i < 0 ?
+ row.length-1
+ : i]
+ return this },
+ right: function(){
+ var cur = this.current
+ var row = this.getRow(cur)
+ var i = row.indexOf(cur) + 1
+ this.current = row[i >= row.length ?
+ 0
+ : i]
+ return this },
+ up: function(){
+ this.current = this.getImage('above')
+ return this },
+ down: function(){
+ this.current = this.getImage('below')
+ return this },
+
+ // XXX
+ select: function(){
+ },
+
+ show: function(){
+ this.lightbox.show()
+ return this },
+
+ // XXX
+ load: function(urls){
+ },
+ setup: function(dom){
+ var that = this
+ this.dom = dom
+
+ this.dom.addEventListener('click', function(evt){
+ var target = evt.target
+ if(target.tagName == 'IMG'
+ // skip images in lightbox...
+ && target.parentElement === that.dom){
+ that.current = target
+ that.show() } })
+ return this },
+}
+
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+// XXX ignore click from blur...
+// XXX might be a good idea to close on click outside the image...
+// XXX esc from context menu closes view...
+var Lightbox = {
+ dom: undefined,
+ gallery: undefined,
+
+ navigation_deadzone: 100,
+ caption_hysteresis: 10,
+ cache_count: 1,
+
+ get url(){
+ return this.dom.querySelector('img').src },
+ set url(url){
+ this.dom.querySelector('img').src = url
+ // remove preview dir...
+ .replace(/\/[0-9]+px\//, '/')
+ // cache...
+ this.cache_count != 0
+ && this.cache() },
+
+ get shown(){
+ return this.dom.style.display == 'block' },
+ show: function(url){
+ this.url = url
+ ?? (this.gallery.current
+ ?? this.gallery.next().current
+ ?? {}).src
+ // set caption...
+ this.dom.setAttribute('caption',
+ (this.gallery.current
+ ?? this.gallery.next().current
+ ?? {})
+ .getAttribute('caption')
+ ?? '')
+ this.dom.style.display = 'block'
+ return this },
+ hide: function(){
+ this.dom.style.display = ''
+ return this },
+ toggle: function(){
+ return this.shown ?
+ this.hide()
+ : this.show() },
+
+ prev: function(){
+ this.gallery.prev().show()
+ return this },
+ next: function(){
+ this.gallery.next().show()
+ return this },
+
+ __cache: undefined,
+ cache: function(){
+ var cache = []
+ var _cache = this.__cache = []
+ var cur = this.gallery.current
+ var images = [...this.gallery.dom.querySelectorAll('img')].slice(1)
+ var i = images.indexOf(cur)
+ var c = this.cache_count ?? 2
+ for(var j=i+1; j<=i+c; j++){
+ cache.push(j >= images.length ?
+ j % images.length
+ : j) }
+ for(var j=i-1; j>=i-c; j--){
+ cache.unshift(j < 0 ?
+ images.length+j
+ : j) }
+ for(i of cache){
+ var img = document.createElement('img')
+ img.src = images[i].src
+ .replace(/\/[0-9]+px\//, '/')
+ _cache.push(img) }
+ return this },
+
+ setup: function(dom, gallery){
+ var that = this
+ this.dom = dom
+ this.gallery = gallery
+ // controls...
+ this.dom.querySelector('.close')
+ .addEventListener('click', function(evt){
+ evt.stopPropagation()
+ that.hide() })
+ // click...
+ var deadzone = this.navigation_deadzone ?? 100
+ this.dom
+ .addEventListener('click', function(evt){
+ // click left/right side of view...
+ // NOTE: this is vewport-relative...
+ evt.clientX < that.dom.offsetWidth / 2 - deadzone/2
+ && that.prev()
+ evt.clientX > that.dom.offsetWidth / 2 + deadzone/2
+ && that.next() })
+ // mouseofver...
+ var hysteresis = this.caption_hysteresis ?? 10
+ this.dom
+ .addEventListener('mousemove', function(evt){
+ // indicate action...
+ if(evt.clientX < that.dom.offsetWidth / 2 - deadzone/2){
+ that.dom.classList.contains('clickable')
+ || that.dom.classList.add('clickable')
+ } else if( evt.clientX > that.dom.offsetWidth / 2 + deadzone/2){
+ that.dom.classList.contains('clickable')
+ || that.dom.classList.add('clickable')
+ } else {
+ that.dom.classList.contains('clickable')
+ && that.dom.classList.remove('clickable') }
+ // show/hide caption...
+ // hysteresis:
+ // +---+-- off
+ // | |
+ // v ^
+ // | |
+ // on -+---+
+ evt.clientY > that.dom.offsetHeight / 2 + hysteresis
+ && that.dom.classList.add('show-caption')
+ evt.clientY < that.dom.offsetHeight / 2 - hysteresis
+ && that.dom.classList.remove('show-caption') })
+ return this },
+}
+
+
+
+//---------------------------------------------------------------------
+
+var setupGallery = function(gallery){
+ return {__proto__: Gallery}
+ .setup(gallery) }
+
+var setup = function(){
+ patchFlexRows([...document.querySelectorAll('.gallery>img')])
+
+ var galleries = document.body.querySelectorAll('.gallery')
+ for(var gallery of galleries){
+ // XXX this is wrong...
+ window.gallery = setupGallery(gallery) }
+ // keyboard...
+ document.addEventListener('keydown', function(evt){
+ var key = evt.key
+ if(key in keyboard){
+ keyboard[key](evt) } })
+ window.addEventListener('resize', function(){
+ patchFlexRows([...document.querySelectorAll('.gallery>img')]) })
+}
+
+
+
+//---------------------------------------------------------------------
+// vim:set ts=4 sw=4 :