diff --git a/web/shared/css/base.css b/web/shared/css/base.css index 4da0ed4..d9a2069 100644 --- a/web/shared/css/base.css +++ b/web/shared/css/base.css @@ -1,13 +1,21 @@ +@import url('https://fonts.googleapis.com/css2?family=Faustina:wght@400;550&display=swap'); + +/* + * Units + */ + +:root { + --spacing: 0.375rem; +} /* * Typography */ -@import url('https://fonts.googleapis.com/css2?family=Faustina:wght@400;550&display=swap'); - html { font-family: 'Faustina', serif; font-weight: 400; + text-rendering: optimizeLegibility; } body { @@ -19,6 +27,9 @@ body { h1, h2, h3, h4, h5, h6 { font-weight: 550; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: greyscale; } h1 { @@ -60,7 +71,7 @@ h4, h5, h6 { */ nav ul { - padding: calc(0.7862rem / 2); + padding: var(--spacing); margin: 0; list-style: none; @@ -69,12 +80,16 @@ nav ul { } nav ul li { - margin: calc(0.7862rem / 2); + margin: var(--spacing); display: block; + + font-size: 0.9em; + letter-spacing: 0.07em; + text-transform: uppercase; } header { - padding: 1rem 0.7862rem; + padding: 1rem calc(var(--spacing) * 2); } diff --git a/web/shared/css/enlarge.css b/web/shared/css/enlarge.css new file mode 100644 index 0000000..e4def00 --- /dev/null +++ b/web/shared/css/enlarge.css @@ -0,0 +1,63 @@ +.enlarge { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 100; + overflow: hidden; + + transition: 0.5s opacity; + background: rgba(0, 0, 0, 0.95); +} + +.enlarge-close, .enlarge-zoom-controls { + font-size: 0.9em; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.enlarge-close { + padding: calc(var(--spacing) * 2); + + position: fixed; + top: 0; + right: 0; + z-index: 100; + color: rgba(255, 255, 255, 0.5); +} + +.enlarge-zoom-controls { + position: fixed; + top: 0; + left: 0; + z-index: 100; +} + +.enlarge-zoom-in, +.enlarge-zoom-out { + padding: calc(var(--spacing) * 2); + + display: inline-block; + color: rgba(255, 255, 255, 0.5); +} + +.enlarge-contents { + position: relative; + transform-origin: top left; +} + +.enlarge-photo { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} +.enlarge-preload { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/web/shared/css/gallery.css b/web/shared/css/gallery.css index 353a364..b1c60ec 100644 --- a/web/shared/css/gallery.css +++ b/web/shared/css/gallery.css @@ -1,25 +1,47 @@ +:root { + --gallery-spacing: var(--spacing); + --gallery-base-height: 240px; +} + +@media screen and (max-width: 900px) { + :root { + --gallery-base-height: 200px; + } +} + +@media screen and (max-width: 700px) { + :root { + --gallery-base-height: 160px; + } +} + +@media screen and (max-width: 500px) { + :root { + --gallery-base-height: 120px; + } +} + .gallery { - padding: calc(0.7862rem / 2); + padding: var(--gallery-spacing); + padding-right: 0; display: flex; flex-wrap: wrap; } +.gallery-item { + margin-right: -0.5px; +} + .gallery-item picture { - margin: calc(0.7862rem / 2); + margin: var(--gallery-spacing); display: block; } .gallery-item img { display: block; - height: 280px; /* 320px is native size */ + height: calc(var(--gallery-base-height) * 1.2); /* 320px is native size on desktop, 160px is native size on mobile */ width: auto; -} - -@media screen and (max-width: 900px) { - .gallery-item img { - height: 160px; /* 160px is native size */ - width: auto; - } + object-fit: cover; } diff --git a/web/shared/js/bleh.js b/web/shared/js/bleh.js new file mode 100644 index 0000000..c51709e --- /dev/null +++ b/web/shared/js/bleh.js @@ -0,0 +1,2 @@ +import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.esm.browser.js'; + diff --git a/web/shared/js/enlarge.js b/web/shared/js/enlarge.js new file mode 100644 index 0000000..718d0fa --- /dev/null +++ b/web/shared/js/enlarge.js @@ -0,0 +1,337 @@ +export default class Enlarge { + /* + prefix = '#view-'; + ele = null; + closeEle = null; + contentsEle = null; + photoEle = null; + preloadEle = null; + + zoom = 0; + x = 0; + y = 0; + + mouseDown = false; + + preservedScroll = {}; + preloadEles = {}; + + openCloseTimeout = null; + */ + + constructor(ele, prefix='#view-') { + this.prefix = prefix; + this.zoom = 0; + this.x = 0; + this.y = 0; + this.mouseDown = false; + this.preservedScroll = {}; + this.preloadEles = {}; + + this.constructWrapper(); + this.bindZoomControls(); + + this.bindSize(); + this.bindWindow(); + + this.bindTouch(); + this.bindDrag(); + this.bindScroll(); + + ele.appendChild(this.ele); + } + + constructWrapper() { + this.ele = document.createElement('div'); + this.ele.classList.add('enlarge'); + this.ele.style.display = 'none'; + this.ele.style.opacity = '0'; + + this.zoomControlsEle = document.createElement('div'); + this.zoomControlsEle.classList.add('enlarge-zoom-controls'); + this.ele.appendChild(this.zoomControlsEle); + + this.zoomInEle = document.createElement('a'); + this.zoomInEle.classList.add('enlarge-zoom-in'); + this.zoomInEle.innerText = 'Zoom +'; + this.zoomInEle.href = '#'; + this.zoomControlsEle.appendChild(this.zoomInEle); + + this.zoomOutEle = document.createElement('a'); + this.zoomOutEle.classList.add('enlarge-zoom-out'); + this.zoomOutEle.innerText = 'Zoom -'; + this.zoomOutEle.href = '#'; + this.zoomControlsEle.appendChild(this.zoomOutEle); + + this.closeEle = document.createElement('a'); + this.closeEle.classList.add('enlarge-close'); + this.closeEle.innerText = 'Close'; + this.closeEle.href = '#'; + this.ele.appendChild(this.closeEle); + + this.contentsEle = document.createElement('div'); + this.contentsEle.classList.add('enlarge-contents'); + this.ele.appendChild(this.contentsEle); + + this.preloadEle = document.createElement('img'); + this.preloadEle.classList.add('enlarge-preload'); + this.preloadEle.src = ''; + this.contentsEle.appendChild(this.preloadEle); + + this.photoEle = document.createElement('img'); + this.photoEle.classList.add('enlarge-photo'); + this.photoEle.src = ''; + this.contentsEle.appendChild(this.photoEle); + } + + bindZoomControls() { + this.zoomInEle.addEventListener('click', (e) => { + e.preventDefault(); + this.zoomAt(this.zoom * +0.25, this.viewportSize.w / 2, this.viewportSize.h / 2); + this.draw(); + }, false); + this.zoomOutEle.addEventListener('click', (e) => { + e.preventDefault(); + this.zoomAt(this.zoom * -0.25, this.viewportSize.w / 2, this.viewportSize.h / 2); + this.draw(); + }, false); + } + + bindSize() { + this.preloadEle.addEventListener('load', this.updateSize.bind(this), false); + this.photoEle.addEventListener('load', this.updateSize.bind(this), false); + } + + bindWindow() { + window.addEventListener('resize', this.updateSize.bind(this), false); + } + + bindTouch() { + + } + + bindDrag() { + this.ele.addEventListener('pointerdown', (e) => { + this.mouseDown = true; + this.contentsEle.style.cursor = 'grabbing'; + }, false); + this.ele.addEventListener('pointermove', (e) => { + e.preventDefault(); + if (this.mouseDown) { + this.pan(e.movementX, e.movementY); + this.draw(); + } + }, false); + this.ele.addEventListener('pointerup', (e) => { + this.mouseDown = false; + this.contentsEle.style.cursor = 'grab'; + }, false); + } + + bindScroll() { + this.ele.addEventListener('wheel', (e) => { + e.preventDefault(); + // Assume full screen + this.zoomAt(e.deltaY * -0.002 * this.zoom, e.clientX, e.clientY); + this.draw(); + }, false); + } + + openHash() { + const hash = window.location.hash; + if (!hash.startsWith(this.prefix)) { + this.close(); + return; + } + const id = hash.replace(this.prefix, ''); + this.open(id); + } + + watchHash() { + window.addEventListener('hashchange', this.openHash.bind(this), false); + this.openHash(); + } + + setPreload(id) { + // https://caniuse.com/#feat=mdn-javascript_operators_optional_chaining + let preload = null; + if (this.preloadEles[id]) { + preload = this.preloadEles[id].currentSrc; + } + + if (preload) { + this.preloadEle.src = preload; + this.preloadEle.display = 'block'; + } else { + this.preloadEle.src = ''; + this.preloadEle.display = 'none'; + } + } + + setPhoto(id) { + this.zoom = 0; + this.x = 0; + this.y = 0; + if (id) { + this.photoEle.src = id; + } else { + this.photoEle.src = ''; + } + } + + // Update the size of the image + updateSize() { + this.contentsEle.style.width = `${this.size.w}px`; + this.contentsEle.style.height = `${this.size.h}px`; + console.debug(`updated size to ${this.size.w}, ${this.size.h}`); + this.draw(); + } + + draw() { + this.zoom = Enlarge.clampZoom(this.zoom, this.baseScale); + this.y = Enlarge.clamp(this.y, this.viewportSize.h, this.size.h * this.zoom * this.baseScale); + this.x = Enlarge.clamp(this.x, this.viewportSize.w, this.size.w * this.zoom * this.baseScale); + this.contentsEle.style.transform = `translate3d(${this.x}px, ${this.y}px, 0) scale(${this.zoom * this.baseScale})`; + } + + pan(x, y) { + this.x += x; + this.y += y; + } + + zoomAt(by, x, y) { + const newZoom = Enlarge.clampZoom(this.zoom + by, this.baseScale); + // Change ratio + const changedRatio = newZoom / this.zoom; + // Rescale existing offset + this.x *= changedRatio; + this.y *= changedRatio; + // Rescale cursor offset + this.x -= (changedRatio - 1) * x; + this.y -= (changedRatio - 1) * y; + // Update new zoom + this.zoom = newZoom; + } + + static clamp(translation, viewport, size) { + if (viewport > size) { + // Ensure centred + return (viewport - size) / 2; + } + if (translation > 0) { + // Start side + return 0; + } else if (translation < (viewport - size)) { + // End side + return viewport - size; + } else { + return translation; + } + } + + static clampZoom(zoom, baseScale) { + // Cap high resolution scale at 1.5 + // Cap low resolution zoom at 1.5 + const maxZoom = Math.max(1.5 / baseScale, 1.5); + return Math.min(Math.max(zoom, 1), maxZoom); + } + + get baseScale() { + if (this.aspectRatio.ar > this.viewportSize.w / this.viewportSize.h) { + // Photo is clamped to width + return this.viewportSize.w / this.size.w; + } else { + // Photo is clamped to height + return this.viewportSize.h / this.size.h; + } + } + + get viewportSize() { + return { + w: this.ele.offsetWidth, + h: this.ele.offsetHeight, + }; + } + + get aspectRatio() { + return { + ar: this.size.w / this.size.h, + }; + } + + get size() { + if (this.photoEle.naturalWidth > 0) { + return { + w: this.photoEle.naturalWidth, + h: this.photoEle.naturalHeight, + }; + } else { + return { + w: this.preloadEle.naturalWidth, + h: this.preloadEle.naturalHeight, + }; + } + } + + open(id) { + this.setPreload(id); + this.setPhoto(id); + + this.show(); + } + + close() { + this.setPreload(null); + this.setPhoto(null); + + this.hide(); + } + + show() { + // Capture scroll position + this.preservedScroll.x = window.scrollX; + this.preservedScroll.y = window.scrollY; + + this.ele.style.display = 'block'; + this.ele.style.pointerEvents = 'all'; + document.body.style.overflow = 'hidden'; + + if (this.openCloseTimeout) { + clearTimeout(this.openCloseTimeout); + } + this.openCloseTimeout = setTimeout(() => { + this.ele.style.opacity = '1.0'; + }, 100); + } + + hide() { + // Restore scroll position + window.scrollTo(this.preservedScroll.x, this.preservedScroll.y); + + this.ele.style.opacity = '0'; + this.ele.style.pointerEvents = 'none'; + document.body.style.overflow = 'visible'; + + if (this.openCloseTimeout) { + clearTimeout(this.openCloseTimeout); + } + this.openCloseTimeout = setTimeout(() => { + this.ele.style.pointerEvents = 'all'; + this.ele.style.display = 'none'; + }, 1000); + } + + register(id, ele) { + // Simple solution + //ele.href = this.prefix + id; + // Semantic preservation solution + ele.addEventListener('click', (e) => { + e.preventDefault(); + window.location.hash = this.prefix + id; + }, false); + } + + registerPreload(id, ele) { + this.preloadEles[id] = ele; + } +} diff --git a/web/shared/js/gallery.js b/web/shared/js/gallery.js index ecde1a3..13a0ea8 100644 --- a/web/shared/js/gallery.js +++ b/web/shared/js/gallery.js @@ -1,13 +1,18 @@ class LayoutEngine { + /* rects = []; - gap = 6.28333 * 2; - baseHeight = 200; + gap = 12; + baseHeight = 100; // maxHeight = 320; viewportWidth = 1024; + */ constructor() { - + this.rects = []; + this.gap = 12; + this.baseHeight = 100; + this.viewportWidth = 1024; } // Register a new rectangle with an aspect ratio of ar at index @@ -27,29 +32,34 @@ class LayoutEngine { // TODO } - // Generate a list of heights for each rectangle + // Generate a list of dimensions for each rectangle calculate() { - let heights = []; + let dimensions = []; let currentWidth = this.gap; let currentIndex = 0; + let lastHeight = this.baseHeight * 1.2; + // Behave like a browser: try to fit as many in a row with baseHeight for (let index = 0; index < this.rects.length; index++) { // Get the next width to add - const rectWidth = Math.ceil(this.baseHeight * this.rects[index].ar) + this.gap; + const rectWidth = (this.baseHeight * this.rects[index].ar) + this.gap; // If the next width is too wide, resolve the current rectangles - console.debug(currentWidth, rectWidth); if (currentWidth + rectWidth > this.viewportWidth) { const gapTotal = this.gap * (index - currentIndex + 1); - const widthScale = (this.viewportWidth - gapTotal) / (currentWidth - gapTotal); - const heightScale = widthScale; + const scale = (this.viewportWidth - gapTotal) / (currentWidth - gapTotal); + const rectHeight = this.baseHeight * scale; + lastHeight = rectHeight; + // Scale up every previous rectangle - const rectHeight = this.baseHeight * widthScale; for (; currentIndex < index; currentIndex++) { - heights.push(rectHeight); + dimensions.push({ + h: rectHeight, + w: rectHeight * this.rects[currentIndex].ar, + }) } currentWidth = this.gap; } @@ -59,17 +69,20 @@ class LayoutEngine { // Set remainder to a decent height for (; currentIndex < this.rects.length; currentIndex++) { - heights.push(this.baseHeight) + dimensions.push({ + h: lastHeight, + w: lastHeight * this.rects[currentIndex].ar, + }) } - return heights; + return dimensions; } /* * Code for a future implementation that emits events update() { - console.debug("updating layout"); + console.debug('updating layout'); } @@ -91,44 +104,69 @@ export default class Gallery { this.ele = ele; this.layoutEngine = new LayoutEngine(); + this.grabGap(); + this.grabBaseHeight(); + this.grabViewportWidth(); + this.registerViewport(); this.registerInitial(); this.draw(); } + // Extract gap values + grabGap() { + // Simple implementation to guess from padding values + const px = window.getComputedStyle(this.ele).getPropertyValue('padding-left').replace('px', ''); + this.layoutEngine.gap = parseInt(px) * 2 || 0; + } + + // Extract baseHeight + grabBaseHeight() { + const px = window.getComputedStyle(this.ele).getPropertyValue('--gallery-base-height').replace('px', ''); + this.layoutEngine.baseHeight = parseInt(px) || 0; + } + + // Extract viewport width + grabViewportWidth() { + this.layoutEngine.viewportWidth = window.innerWidth; + } + // Inform our engine of viewport width registerViewport() { window.addEventListener('resize', () => { if (this.layoutEngine.viewportWidth == window.innerWidth) { return; } - this.layoutEngine.viewportWidth = window.innerWidth; + this.grabBaseHeight(); + this.grabViewportWidth(); //this.layoutEngine.scheduleUpdate(); + this.draw(); }); } // Register initial elements registerInitial() { const galleryItemEles = this.ele.querySelectorAll('.gallery-item'); - galleryItemEles.forEach(ele => { + for (const ele of galleryItemEles) { const ar = ele.dataset.ar; this.layoutEngine.insert(ar); - }); + } } - // Calculate heights and draw them + // Calculate dimensions and draw them draw() { - const heights = this.layoutEngine.calculate(); + const dimensions = this.layoutEngine.calculate(); const galleryItemEles = this.ele.querySelectorAll('.gallery-item'); - galleryItemEles.forEach((ele, index) => { - const height = heights[index]; - if (!height) { - console.error(`Missing height for element at ${index}`); + for (const [index, ele] of galleryItemEles.entries()) { + const dimension = dimensions[index]; + if (!dimension) { + console.error(`Missing dimensions for element at ${index}`); return; } - ele.querySelectorAll("img").forEach(ele => { - ele.style.height = `${height}px`; - }); - }); + for (const imgEle of ele.querySelectorAll('img')) { + imgEle.style.height = `${dimension.h}px`; + imgEle.style.width = `${dimension.w}px`; + } + } } } diff --git a/web/shared/js/view.js b/web/shared/js/view.js index 417f91e..6dab2b0 100644 --- a/web/shared/js/view.js +++ b/web/shared/js/view.js @@ -1,10 +1,25 @@ import Gallery from './gallery.js'; +import Enlarge from './enlarge.js'; // Bind elements -const galleryEle = document.querySelector("main.gallery"); +const galleryEle = document.querySelector('main.gallery'); const gallery = new Gallery(galleryEle); +const enlargeEles = document.querySelectorAll('[data-enlarge]'); +const enlarge = new Enlarge(document.body); +for (const ele of enlargeEles) { + enlarge.register(ele.dataset.enlarge, ele); +} + +const enlargePreloadEles = document.querySelectorAll('[data-enlarge-preload-for]'); +for (const ele of enlargePreloadEles) { + enlarge.registerPreload(ele.dataset.enlargePreloadFor, ele); +} + +enlarge.watchHash(); + // Debug window.g = gallery; +window.e = enlarge; diff --git a/web/view/index.tmpl b/web/view/index.tmpl index 9bc10ce..e77e006 100644 --- a/web/view/index.tmpl +++ b/web/view/index.tmpl @@ -6,6 +6,7 @@ Photos +