@thegetty/quire-11ty
Advanced tools
Comparing version 1.0.0-rc.18 to 1.0.0-rc.19
@@ -61,4 +61,3 @@ const path = require('path') | ||
<script src="https://cdn.jsdelivr.net/npm/@digirati/canvas-panel-web-components@1.0.56" type="module"></script> | ||
<script src="https://cdn.jsdelivr.net/npm/@iiif/vault-helpers@latest/dist/index.umd.js"></script> | ||
<script src="/_assets/javascript/application/canvas-panel-web-components-1.0.68.js" type="module"></script> | ||
@@ -65,0 +64,0 @@ ${publisherLinks} |
import { LitElement, css, html, render, unsafeCSS } from 'lit' | ||
import { createRef, ref } from 'lit/directives/ref.js' | ||
class ImageSequence extends LitElement { | ||
import { imageSequenceStyles } from './styles.js' | ||
static styles = css` | ||
:host { | ||
position: relative; | ||
display: flex; | ||
justify-content: center; | ||
max-width: 100vw; | ||
max-height: 100vh; | ||
} | ||
// TODO: Ensure buffer size is > then performRotation increment (set bufferSize larger, make prefetch return a promise that we .then() ) | ||
// TODO: Raise and set an error message on the status-overlay component if fetches error out and cursor: not-allowed | ||
.image-sequence.interactive { | ||
height: 100%; | ||
cursor: grab; | ||
} | ||
class ImageSequence extends LitElement { | ||
.overlay { | ||
display: flex; | ||
justify-content: center; | ||
align-items: center; | ||
position: absolute; | ||
top: 0; | ||
left: 0; | ||
width: 100%; | ||
height: 100%; | ||
background: rgba(0,0,0,0); | ||
color: white; | ||
transition: all 0.25s linear; | ||
} | ||
static styles = [ imageSequenceStyles ] | ||
.overlay.visible { | ||
opacity: 1; | ||
} | ||
.overlay:not(.visible) { | ||
opacity: 0; | ||
} | ||
.overlay:hover { | ||
background: rgba(0,0,0,0.6); | ||
} | ||
.overlay.loading.visible { | ||
animation: loading-overlay 1s infinite alternate; | ||
} | ||
@keyframes loading-overlay { | ||
from { | ||
opacity: 0.6; | ||
} | ||
to { | ||
opacity: 1; | ||
} | ||
} | ||
.description { | ||
display: flex; | ||
flex-direction: column; | ||
justify-content: space-around; | ||
align-items: center; | ||
opacity: 0; | ||
transition: opacity 0.25s linear; | ||
} | ||
.description__icon { | ||
fill: white; | ||
height: 2em; | ||
} | ||
.overlay:hover .description { | ||
opacity: 1; | ||
} | ||
slot[name='images'] img { | ||
display: block; | ||
pointer-events: none; | ||
user-select: none; | ||
width: 100%; | ||
height: 100%; | ||
} | ||
slot[name='images'] img:not(.placeholder) { | ||
position: absolute; | ||
top: 0; | ||
left: 0; | ||
} | ||
slot[name='images'] img.visible { | ||
opacity: 1; | ||
object-fit: contain; | ||
} | ||
slot[name='images'] img:not(.visible) { | ||
opacity: 0; | ||
} | ||
slot[name='images'] img.fade-in { | ||
animation: fade-in 0.25s 1 linear; | ||
} | ||
@keyframes fade-in { | ||
from { | ||
opacity: 0; | ||
} | ||
to { | ||
opacity: 1; | ||
} | ||
} | ||
slot[name='placeholder-image'] img.loading { | ||
opacity: 1; | ||
filter: brightness(0.4); | ||
animation: loading-image 1s linear infinite alternate; | ||
} | ||
@keyframes loading-image { | ||
from { | ||
filter: brightness(0.4); | ||
} | ||
to { | ||
filter: brightness(0.2); | ||
} | ||
} | ||
slot[name='placeholder-image'] img { | ||
opacity: 0; | ||
transition: opacity 0.25s linear; | ||
object-fit: cover; | ||
}` | ||
static properties = { | ||
index: { | ||
bufferSize: { | ||
type: Number, | ||
state: true, | ||
}, | ||
@@ -141,6 +22,2 @@ didInteract: { | ||
}, | ||
visible: { | ||
type: Boolean, | ||
state: true, | ||
}, | ||
images: { | ||
@@ -150,2 +27,17 @@ type: Array, | ||
}, | ||
imageUrls: { | ||
attribute: 'image-urls', | ||
type: String, | ||
}, | ||
index: { | ||
type: Number, | ||
}, | ||
intrinsicHeight: { | ||
type: Number, | ||
state: true, | ||
}, | ||
intrinsicWidth: { | ||
type: Number, | ||
state: true, | ||
}, | ||
rotateToIndex: { | ||
@@ -157,16 +49,106 @@ attribute: 'rotate-to-index', | ||
type: Number, | ||
}, | ||
visible: { | ||
type: Boolean, | ||
state: true, | ||
} | ||
} | ||
get allImagesLoaded() { | ||
return this.imagesLoaded === this.images.length | ||
canvasRef = createRef() | ||
/** | ||
* @property bufferReady | ||
* | ||
* Returns an array of indexes to buffer | ||
* | ||
**/ | ||
get indexesToBuffer() { | ||
// NB: Calculated one length higher to protect against numerical under run | ||
return Array(this.bufferSize).fill(0).map( (_,i) => ( this.images.length + this.index + i - Math.round(this.bufferSize/2) ) % this.images.length ) | ||
} | ||
// Fires when this component is visible | ||
_loadImages() { | ||
const imageElements = this.images.map( (img,i) => { | ||
fetch(img).then( this.imagesLoaded++ ) | ||
}) | ||
/** | ||
* @property bufferReady | ||
* | ||
* Returns true if the buffer is loaded ahead and behind of `index` | ||
* | ||
**/ | ||
get bufferReady() { | ||
return this.images.filter( (img,j) => this.indexesToBuffer.includes(j) && img !== null ).length === this.bufferSize | ||
} | ||
/** | ||
* @property buffered | ||
* | ||
* Returns true if the buffer is loaded ahead and behind of `index` | ||
* | ||
**/ | ||
get bufferedPct() { | ||
return Math.floor( this.images.filter( (img,j) => this.indexesToBuffer.includes(j) && img !== null ).length / this.bufferSize * 100 ) | ||
} | ||
get someImagesLoaded() { | ||
return this.images.some( i => i !== null ) | ||
} | ||
/** | ||
* @function #fetchImage | ||
* @param url {string} - image URL to fetch | ||
* @param seqIndex {Number} - index to store this image | ||
* | ||
* Fetches `url`, converts it into a blob and stores the image data, optionally drawing to the canvas. | ||
* | ||
* The in-flight fetch is stored in requests[seqIndex] for cancellation and request deduplication. | ||
**/ | ||
#fetchImage(url,seqIndex) { | ||
const req = new Request(url) | ||
if (this.requests[seqIndex]) { | ||
return | ||
} | ||
this.requests[seqIndex] = fetch(req) | ||
.then( (resp) => resp.blob() ) | ||
.then( (blob) => window.createImageBitmap(blob) ) | ||
.then( (bmp) => { | ||
if (this.intrinsicHeight === 0) { | ||
this.intrinsicHeight = bmp.height | ||
this.intrinsicWidth = bmp.width | ||
} | ||
this.images[seqIndex] = bmp | ||
// Draw if the user hasn't already gone past this index | ||
if (this.index===seqIndex) { | ||
this.#paintCanvas(bmp) | ||
return | ||
} | ||
}) | ||
.then( () => { | ||
this.requests[seqIndex] = null | ||
this.requestUpdate() | ||
}) | ||
.catch( (err) => { | ||
console.error(err) | ||
}) | ||
} | ||
connectedCallback() { | ||
super.connectedCallback() | ||
const callback = (entries,observer) => { | ||
entries.forEach( (entry) => { | ||
if (entry.isIntersecting) { | ||
this.visible = true | ||
observer.disconnect() | ||
} | ||
}) | ||
} | ||
const options = { root: null, threshold: 0.5 } | ||
// Observes this component against the viewport to trigger image preloads | ||
const io = new IntersectionObserver(callback, options) | ||
io.observe(this) | ||
} | ||
constructor() { | ||
@@ -177,3 +159,4 @@ super() | ||
this.description = 'Click and drag horizontally to rotate image' | ||
this.images = this.getAttribute('items').split(',') | ||
this.imageUrls = this.getAttribute('items').split(',') | ||
this.posterImageSrc = this.imageUrls.length > 0 ? this.imageUrls[0] : '' | ||
this.isContinuous = this.getAttribute('continuous') === 'true' | ||
@@ -186,26 +169,16 @@ this.isInteractive = this.getAttribute('interactive') === 'true' | ||
// Internal state | ||
this.imageData = [] | ||
const pctToBuffer = 0.2 | ||
this.bufferSize = Math.ceil( this.imageUrls.length * pctToBuffer ) | ||
this.blitting = null // null | animationFrameRequestId | ||
this.images = Array(this.imageUrls.length).fill(null) // Array< null | ImageBitmap > | ||
this.requests = Array(this.imageUrls.length).fill(null) // Array< null | Promise > | ||
this.visible = false | ||
this.index = 0 | ||
this.intrinsicHeight = 0 | ||
this.intrinsicWidth = 0 | ||
this.oldIndex = null | ||
this.oldX = null | ||
this.imagesLoaded = 0 | ||
this.totalCanvases = this.images.length | ||
this.imageCount = this.imageUrls.length | ||
// Set up observable and mouse events | ||
this._loadImages() | ||
// TODO: Setup observer and run isVisible() when we're at least one screen away | ||
// const io = new IntersectionObserver( (entries,observer) => { | ||
// entries.forEach(entry => { | ||
// if (entry.intersectionRatio > 0) { | ||
// this.visible = true | ||
// this._isObservable() | ||
// } | ||
// }) | ||
// }) | ||
// io.observe(this) | ||
if (this.isInteractive) { | ||
@@ -257,12 +230,5 @@ this.addEventListener('mousemove', this.handleMouseMove.bind(this)) | ||
// getClampedIndex(index) { | ||
// if (!this.images) return 0 | ||
// return Math.max(Math.min(index, this.images.length - 1), 0) | ||
// } | ||
handleMouseMove({ buttons, clientX }) { | ||
if (buttons) { | ||
if (this.didInteract) { | ||
this.didInteract = true | ||
} | ||
this.didInteract = true | ||
@@ -275,8 +241,8 @@ this.hideOverlays() | ||
this.isReversed | ||
? this.previousCanvas(deltaIndex) | ||
: this.nextCanvas(deltaIndex) | ||
? this.previousImage(deltaIndex) | ||
: this.nextImage(deltaIndex) | ||
} else if (deltaX < 0) { | ||
this.isReversed | ||
? this.nextCanvas(deltaIndex) | ||
: this.previousCanvas(deltaIndex) | ||
? this.nextImage(deltaIndex) | ||
: this.previousImage(deltaIndex) | ||
} | ||
@@ -290,9 +256,2 @@ } | ||
hideAllImages() { | ||
this.images.forEach((element) => { | ||
element.classList.remove('visible') | ||
}) | ||
} | ||
hideOverlays() { | ||
@@ -304,22 +263,60 @@ this.querySelectorAll('.overlay').forEach((element) => { | ||
hideImage(imageElement) { | ||
imageElement.className = '' | ||
/** | ||
* @function nextImage | ||
* @param {Integer} n Number of steps between start index and end index | ||
* | ||
* Set the sequence image to the index `n` steps after the current index | ||
*/ | ||
nextImage(n=1) { | ||
const newIndex = this.index + n >= this.imageCount | ||
? this.index + n - this.imageCount | ||
: this.index + n | ||
this.index = newIndex | ||
} | ||
hideLoadingOverlay() { | ||
this.querySelector('.overlay').classList.remove('visible') | ||
/** | ||
* @function #draw | ||
* | ||
* Performs drawing operations against `this.context` | ||
**/ | ||
#draw(image) { | ||
this.context.drawImage(image,0,0) | ||
} | ||
/** | ||
* Set the sequence canvas to the index `n` steps after the current index | ||
* @function #paintCanvas | ||
* @param {ImageBitmap} - image - image to paint | ||
* | ||
* @param {Integer} n Number of steps between start index and end index | ||
*/ | ||
nextCanvas(n=1) { | ||
const newIndex = this.index + n >= this.totalCanvases | ||
? this.index + n - this.totalCanvases | ||
: this.index + n | ||
this.index = newIndex | ||
* Paints the `canvas` element with the image from this.index | ||
**/ | ||
#paintCanvas(image) { | ||
if (!this.canvasRef.value) { | ||
return | ||
} | ||
this.context ??= this.canvasRef.value.getContext('2d') | ||
if (image) { | ||
window.cancelAnimationFrame(this.blitting) | ||
this.blitting = window.requestAnimationFrame( () => this.#draw(image) ) | ||
return | ||
} | ||
if (!this.images[this.index]) { | ||
this.#fetchImage(this.imageUrls[this.index],this.index) | ||
return | ||
} | ||
window.cancelAnimationFrame(this.blitting) | ||
this.blitting = window.requestAnimationFrame( () => this.#draw(this.images[this.index]) ) | ||
} | ||
/** | ||
* @function willUpdate | ||
* @param changedProperties | ||
* | ||
* `lit` lifecycle method for changed properties | ||
* | ||
**/ | ||
willUpdate(changedProperties) { | ||
@@ -329,6 +326,39 @@ if (changedProperties.has('rotateToIndex') && this.rotateToIndex!==false) { | ||
} | ||
if (changedProperties.has('index') && this.someImagesLoaded) { | ||
this.#preloadImages() | ||
if (this.bufferReady) { | ||
this.#paintCanvas() | ||
} | ||
} | ||
if (changedProperties.has('visible') && !this.visible) { | ||
this.#preloadImages() | ||
} | ||
} | ||
/** | ||
* Animates a rotation by stepping through canvases from the current index to the provided `newValue` | ||
* @function #preloadImages | ||
* | ||
* Loads the k images behind and ahead of this.index | ||
**/ | ||
#preloadImages() { | ||
if (!this.images.some(i => i === null)) { return } | ||
// Really just making buffer counting ergonomic / readable here | ||
// const indexesToBuf = Array(this.bufferSize).fill(0).map( (_,i) => ( this.images.length + this.index + i - Math.round(this.bufferSize/2) ) % this.images.length ) | ||
this.images.forEach( (image,i) => { | ||
// Skip anything out of our range or already loaded | ||
if ( !this.indexesToBuffer.includes(i) || image !== null ) { | ||
return | ||
} | ||
const url = this.imageUrls[i] | ||
this.#fetchImage(url,i) | ||
}) | ||
} | ||
/** | ||
* Animates a rotation by stepping through images from the current index to the provided `newValue` | ||
*/ | ||
@@ -341,4 +371,2 @@ performRotation(indexToMove) { | ||
*/ | ||
// TODO: | ||
console.log(this.index) | ||
if (this.index === indexToMove) { | ||
@@ -355,5 +383,5 @@ this.rotateToIndex = false | ||
/** | ||
* Step through canvases | ||
* Step through images | ||
*/ | ||
this.nextCanvas() | ||
this.nextImage() | ||
}, this.transition) | ||
@@ -363,8 +391,8 @@ } | ||
/** | ||
* Set the sequence canvas to the index `n` indices before the current index | ||
* Set the sequence image to the index `n` indices before the current index | ||
* @param {Integer} n Number of steps between start index and end index | ||
*/ | ||
previousCanvas(n=1) { | ||
previousImage(n=1) { | ||
const newIndex = this.index - n < 0 | ||
? this.totalCanvases + this.index - n | ||
? this.imageCount + this.index - n | ||
: this.index - n | ||
@@ -374,10 +402,3 @@ this.index = newIndex | ||
// TODO: Add this to the class list if no interaction | ||
showImage(imageElement, shouldFadeIn) { | ||
if (!imageElement) return | ||
shouldFadeIn && imageElement.classList.add('fade-in') | ||
imageElement.classList.add('visible') | ||
} | ||
// TODO: maybe do this in an await this.updateComplete callback? -- synchro to the on-page resources? | ||
// TODO: Consult quire team for expected behavior here | ||
synchronizeSequenceInstances() { | ||
@@ -397,6 +418,4 @@ clearTimeout(this.updateTimer) | ||
const loadingOverlayElement = html`<div slot='loading-overlay' class='${ this.allImagesLoaded ? '' : 'visible'} loading overlay'>Loading Image Sequence...</div>` | ||
const descriptionOverlay = this.isInteractive ? | ||
html`<div slot='overlay' class="overlay ${ this.oldX === null ? 'visible' : '' }"><span class="description"> | ||
html`<div slot='overlay' class="overlay ${ this.didInteract === false ? 'visible' : '' }"><span class="description"> | ||
<svg class="description__icon"> | ||
@@ -414,12 +433,9 @@ <symbol id="rotation-icon" viewBox="0 0 24 24"> | ||
return html`<div class="image-sequence ${ this.allImagesLoaded ? '' : 'loading' } ${ this.isInteractive ? 'interactive' : '' }"> | ||
<slot name="placeholder-image"> | ||
<img slot="placeholder-image" class="${ this.allImagesLoaded ? '' : 'loading' } placeholder" src="${ this.images.length > 0 ? this.images[0] : '' }" > | ||
</slot> | ||
return html`<div class="image-sequence ${ this.bufferReady ? '' : 'loading' } ${ this.isInteractive ? 'interactive' : '' }"> | ||
<slot name="loading-overlay"> | ||
${ loadingOverlayElement } | ||
</slot> | ||
<slot name="images"> | ||
<img slot="images" class="${ this.allImagesLoaded ? 'visible' : '' } ${ this.didInteract ? '' : 'fade-in' } " src="${this.images[this.index]}"> | ||
<div slot="loading-overlay" class='${ this.bufferReady ? '' : 'visible'} loading overlay'> | ||
<div class="buffering-indicator">Loading Image Sequence (${ this.bufferedPct }%)...</div> | ||
</div> | ||
</slot> | ||
<canvas ${ref(this.canvasRef)} height="${this.intrinsicHeight}" width="${this.intrinsicWidth}" class="${ this.someImagesLoaded ? 'visible' : '' } ${ this.didInteract ? '' : 'fade-in' }" slot="images"></canvas> | ||
<slot name="overlay"> | ||
@@ -426,0 +442,0 @@ ${ descriptionOverlay } |
@@ -15,2 +15,5 @@ # Changelog | ||
## [1.0.0-rc.19] | ||
## [1.0.0-rc.18] | ||
@@ -17,0 +20,0 @@ |
{ | ||
"name": "@thegetty/quire-11ty", | ||
"version": "1.0.0-rc.18", | ||
"version": "1.0.0-rc.19", | ||
"description": "Quire 11ty static site generator", | ||
@@ -63,2 +63,3 @@ "keywords": [ | ||
"@11ty/is-land": "^3.0.1", | ||
"@digirati/canvas-panel-web-components": "^1.0.68", | ||
"@iiif/parser": "^1.1.2", | ||
@@ -65,0 +66,0 @@ "@iiif/vault": "^0.9.22", |
1028487
282
28216
46