1
0
Fork 0

Add enlarged photo view

main
Ambrose Chua 2020-05-25 23:17:29 +08:00
parent b6613ea168
commit a9a55c3ef9
Signed by: ambrose
GPG Key ID: BC367D33F140B5C2
9 changed files with 550 additions and 51 deletions

View File

@ -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);
}

View File

@ -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%;
}

View File

@ -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;
}

2
web/shared/js/bleh.js Normal file
View File

@ -0,0 +1,2 @@
import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.esm.browser.js';

337
web/shared/js/enlarge.js Normal file
View File

@ -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;
}
}

View File

@ -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`;
}
}
}
}

View File

@ -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;

View File

@ -6,6 +6,7 @@
<title>Photos</title>
<link rel="stylesheet" href="https://local1:2020/css/base.css">
<link rel="stylesheet" href="https://local1:2020/css/gallery.css">
<link rel="stylesheet" href="https://local1:2020/css/enlarge.css">
</head>
<body>
<nav>
@ -20,6 +21,7 @@
<main class="gallery">
{{ range (datasource "samples") }}
<div class="gallery-item preloaded-thumbnail" data-ar="{{ div (index . 1) (index . 2) }}">
<a href="sample/{{ index . 0 }}" data-enlarge="sample/{{ index . 0 }}">
<picture>
<source srcset="
sample/{{ index . 0 | replaceAll ".jpg" "_320.webp"}} 2x,
@ -41,7 +43,8 @@
alt=""
width="{{ div (index . 1) (index . 2) | mul 320 }}"
height="320"
loading="lazy">
loading="lazy"
data-enlarge-preload-for="sample/{{ index . 0 }}">
</picture>
<picture class="preloaded-thumbnail-image">
<source srcset="
@ -66,6 +69,7 @@
height="320"
>
</picture>
</a>
</div>
{{ end }}
</main>

View File

@ -15,17 +15,20 @@ done
for size in ${presizes[*]}; do
for f in sample/*unsplash.jpg; do
echo "Compressing thumbnail $f at $size"
gm convert $f -resize x$size -compress jpeg -quality 60 ${f%.jpg}_pre$size.jpg
gm convert $f -resize x$size -compress webp -quality 40 ${f%.jpg}_pre$size.webp
if [ -f "${f%.jpg}_pre$size.jpg" ]; then
continue
fi
gm convert $f -resize x$size -compress jpeg -quality 40 ${f%.jpg}_pre$size.jpg
gm convert $f -resize x$size -compress webp -quality 35 ${f%.jpg}_pre$size.webp
done
done
for size in ${sizes[*]}; do
for f in sample/*unsplash.jpg; do
echo "Compressing $f at $size"
#if [ -f "${f%.jpg}_$size.jpg" ]; then
# continue
#fi
gm convert $f -resize x$size -compress jpeg -quality 70 ${f%.jpg}_$size.jpg
gm convert $f -resize x$size -compress webp -quality 65 ${f%.jpg}_$size.webp
if [ -f "${f%.jpg}_$size.jpg" ]; then
continue
fi
gm convert $f -resize x$size -compress jpeg -quality 75 ${f%.jpg}_$size.jpg
gm convert $f -resize x$size -compress webp -quality 70 ${f%.jpg}_$size.webp
done
done