@thegetty/quire-11ty
Advanced tools
Comparing version 1.0.0-rc.19 to 1.0.0-rc.20
@@ -6,5 +6,10 @@ import { LitElement, css, html, render, unsafeCSS } from 'lit' | ||
// 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 | ||
/** | ||
* @class ImageSequence | ||
* @description A reactive Lit element for showing and interacting with a sequence of images. | ||
* | ||
* @todo on fetch error raise an error message on the status-overlay component | ||
* and set cursor: not-allowed | ||
*/ | ||
class ImageSequence extends LitElement { | ||
@@ -15,6 +20,10 @@ | ||
static properties = { | ||
bufferSize: { | ||
animationFrame: { | ||
type: Number, | ||
state: true, | ||
}, | ||
bufferSize: { | ||
type: Number, | ||
state: true, | ||
}, | ||
didInteract: { | ||
@@ -59,34 +68,41 @@ type: Boolean, | ||
/** | ||
* @property bufferReady | ||
* @private | ||
* @property #bufferWindow | ||
* | ||
* Returns an array of indexes to buffer | ||
* | ||
**/ | ||
get indexesToBuffer() { | ||
* @returns an array of indexes included in `bufferSize` given `index` | ||
*/ | ||
get #bufferWindow() { | ||
// 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 ) | ||
const imageCount = this.images.length | ||
const windowStart = imageCount + this.index - Math.round(this.bufferSize/2) | ||
return Array(this.bufferSize) | ||
.fill(0) | ||
.map((_, i) => ((windowStart + i) % imageCount)) | ||
} | ||
/** | ||
* @property bufferReady | ||
* @private | ||
* @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 | ||
* @returns true if the buffer is loaded ahead and behind of `index` | ||
*/ | ||
get #bufferReady() { | ||
return this.images.filter((img, j) => this.#bufferWindow.includes(j) && img !== null ).length === this.bufferSize | ||
} | ||
/** | ||
* @property buffered | ||
* | ||
* Returns true if the buffer is loaded ahead and behind of `index` | ||
* | ||
**/ | ||
* @property bufferedPct | ||
* @returns percent of the buffer that is not-null | ||
*/ | ||
get bufferedPct() { | ||
return Math.floor( this.images.filter( (img,j) => this.indexesToBuffer.includes(j) && img !== null ).length / this.bufferSize * 100 ) | ||
return Math.floor( this.images.filter((img, j) => this.#bufferWindow.includes(j) && img !== null).length / this.bufferSize * 100 ) | ||
} | ||
/** | ||
* @property someImagesLoaded | ||
* @returns true if there is at least one image loaded | ||
*/ | ||
get someImagesLoaded() { | ||
return this.images.some( i => i !== null ) | ||
return this.images.some((image) => image !== null) | ||
} | ||
@@ -99,17 +115,17 @@ | ||
* | ||
* Fetches `url`, converts it into a blob and stores the image data, optionally drawing to the canvas. | ||
* Fetches `url`, converts it into a blob and stores the image data in `seqIndex`. | ||
* | ||
* The in-flight fetch is stored in requests[seqIndex] for cancellation and request deduplication. | ||
**/ | ||
#fetchImage(url,seqIndex) { | ||
const req = new Request(url) | ||
* The in-flight fetch is stored in this.requests for cancellation and request deduplication, nulled on completion. | ||
* | ||
* @returns {Promise} fetch resposne | ||
*/ | ||
#fetchImage(url, seqIndex) { | ||
if (this.requests[seqIndex]) return | ||
if (this.requests[seqIndex]) { | ||
return | ||
} | ||
const request = new Request(url) | ||
this.requests[seqIndex] = fetch(req) | ||
.then( (resp) => resp.blob() ) | ||
.then( (blob) => window.createImageBitmap(blob) ) | ||
.then( (bmp) => { | ||
const response = fetch(request) | ||
.then((response) => response.blob()) | ||
.then((blob) => window.createImageBitmap(blob)) | ||
.then((bmp) => { | ||
if (this.intrinsicHeight === 0) { | ||
@@ -120,22 +136,28 @@ this.intrinsicHeight = bmp.height | ||
this.images[seqIndex] = bmp | ||
// Draw if the user hasn't already gone past this index | ||
if (this.index===seqIndex) { | ||
this.#paintCanvas(bmp) | ||
return | ||
} | ||
}) | ||
.then( () => { | ||
.then(() => { | ||
this.requests[seqIndex] = null | ||
this.requestUpdate() | ||
}) | ||
.catch( (err) => { | ||
console.error(err) | ||
.catch((error) => { | ||
console.error(error) | ||
}) | ||
this.requests[seqIndex] = response | ||
return response | ||
} | ||
/** | ||
* @function connectedCallback | ||
* | ||
* `lit` lifecycle method fired the first time the element is connected to the document | ||
* | ||
* Used to register our visibility IntersectionObserver | ||
*/ | ||
connectedCallback() { | ||
super.connectedCallback() | ||
const callback = (entries,observer) => { | ||
entries.forEach( (entry) => { | ||
const callback = (entries, observer) => { | ||
entries.forEach((entry) => { | ||
if (entry.isIntersecting) { | ||
@@ -171,3 +193,3 @@ this.visible = true | ||
const pctToBuffer = 0.2 | ||
this.bufferSize = Math.ceil( this.imageUrls.length * pctToBuffer ) | ||
this.bufferSize = Math.ceil(this.imageUrls.length * pctToBuffer) | ||
this.blitting = null // null | animationFrameRequestId | ||
@@ -191,2 +213,4 @@ this.images = Array(this.imageUrls.length).fill(null) // Array< null | ImageBitmap > | ||
/** | ||
* @function debounce | ||
* | ||
* Returns a function, that, as long as it continues to be invoked, will not | ||
@@ -209,5 +233,6 @@ * be triggered. The function will be called after it stops being called for | ||
* @param {boolean} immediate - whether to call the function immediately or at the end of the timeout | ||
* | ||
* @returns | ||
*/ | ||
debounce(func, wait = 250, immediate = false) { | ||
debounce(fn, wait = 250, immediate = false) { | ||
let timeout = null | ||
@@ -221,3 +246,3 @@ | ||
if (!immediate) { | ||
func.apply(context, args) | ||
fn.apply(context, args) | ||
} | ||
@@ -229,3 +254,3 @@ } | ||
if (callNow) { | ||
func.apply(context, args) | ||
fn.apply(context, args) | ||
} | ||
@@ -235,2 +260,10 @@ } | ||
/** | ||
* @function handleMouseMove | ||
* | ||
* @param {Event.buttons} | ||
* @param {Event.clientX} | ||
* | ||
* Sets interaction flag, hides overlays, and handles reversability check | ||
*/ | ||
handleMouseMove({ buttons, clientX }) { | ||
@@ -240,3 +273,2 @@ if (buttons) { | ||
this.hideOverlays() | ||
if (this.oldX) { | ||
@@ -261,8 +293,2 @@ const deltaX = clientX - this.oldX | ||
hideOverlays() { | ||
this.querySelectorAll('.overlay').forEach((element) => { | ||
element.classList.remove('visible') | ||
}) | ||
} | ||
/** | ||
@@ -285,3 +311,3 @@ * @function nextImage | ||
* Performs drawing operations against `this.context` | ||
**/ | ||
*/ | ||
#draw(image) { | ||
@@ -296,3 +322,3 @@ this.context.drawImage(image,0,0) | ||
* Paints the `canvas` element with the image from this.index | ||
**/ | ||
*/ | ||
#paintCanvas(image) { | ||
@@ -326,19 +352,28 @@ if (!this.canvasRef.value) { | ||
* `lit` lifecycle method for changed properties | ||
* | ||
**/ | ||
*/ | ||
willUpdate(changedProperties) { | ||
// Determine the animation indices, preload them, and then do the rotation | ||
if (changedProperties.has('rotateToIndex') && this.rotateToIndex!==false) { | ||
this.performRotation(this.rotateToIndex) | ||
const frameCount = this.rotateToIndex - this.index | ||
const animationIndices = Array(frameCount).fill(0).map((_, i) => this.index + i + 1) | ||
this.#preloadImages(animationIndices).then(this.animateRotation(this.rotateToIndex)) | ||
} | ||
// Draws `animationFrame` directly to canvas (for use ) | ||
if (changedProperties.has('animationFrame')) { | ||
if (!this.animationFrame) { return } | ||
if (this.images[this.animationFrame] === null) { return } | ||
this.#paintCanvas(this.images[this.animationFrame]) | ||
} | ||
if (changedProperties.has('index') && this.someImagesLoaded) { | ||
this.#preloadImages() | ||
if (this.bufferReady) { | ||
this.#paintCanvas() | ||
} | ||
this.#preloadImages().then(() => { | ||
this.animateRotation(this.index) | ||
}) | ||
} | ||
// Load enough to prepare for interaction | ||
if (changedProperties.has('visible') && !this.visible) { | ||
this.#preloadImages() | ||
this.#preloadImages().then(() => this.#paintCanvas()) | ||
} | ||
@@ -350,18 +385,20 @@ } | ||
* | ||
* Loads the k images behind and ahead of this.index | ||
**/ | ||
#preloadImages() { | ||
if (!this.images.some(i => i === null)) { return } | ||
* Loads images of this.indexesToBuffer -- ahead and behind `this.index` | ||
* | ||
* @returns {Promise} - Promise resolution for all preloads | ||
*/ | ||
#preloadImages(bufferWindow) { | ||
if (!this.images.some(i => i === null)) return Promise.all([]); | ||
// 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 indexesToLoad = bufferWindow ?? this.#bufferWindow | ||
const imageRequests = this.images | ||
.map((image, i) => { | ||
// Skip anything out of our range or already loaded | ||
if (!indexesToLoad.includes(i) || image !== null) return null; | ||
const url = this.imageUrls[i] | ||
return this.#fetchImage(url, i); | ||
}) | ||
.filter((imageRequest) => imageRequest) | ||
const url = this.imageUrls[i] | ||
this.#fetchImage(url,i) | ||
}) | ||
return Promise.all(imageRequests) | ||
} | ||
@@ -372,4 +409,7 @@ | ||
*/ | ||
performRotation(indexToMove) { | ||
if (this.index === indexToMove) return | ||
animateRotation(untilIndex) { | ||
if (this.animationFrame === untilIndex) return | ||
this.animationFrame = this.index | ||
const interval = setInterval(() => { | ||
@@ -379,4 +419,6 @@ /** | ||
*/ | ||
if (this.index === indexToMove) { | ||
if (this.animationFrame === untilIndex) { | ||
this.index = untilIndex | ||
this.rotateToIndex = false | ||
this.synchronizeSequenceInstances() | ||
} | ||
@@ -386,3 +428,3 @@ /** | ||
*/ | ||
if (this.rotateToIndex !== indexToMove) { | ||
if (this.rotateToIndex !== untilIndex) { | ||
clearInterval(interval) | ||
@@ -394,3 +436,3 @@ return | ||
*/ | ||
this.nextImage() | ||
this.animationFrame += 1 | ||
}, this.transition) | ||
@@ -410,3 +452,2 @@ } | ||
// TODO: Consult quire team for expected behavior here | ||
synchronizeSequenceInstances() { | ||
@@ -425,3 +466,2 @@ clearTimeout(this.updateTimer) | ||
render() { | ||
const descriptionOverlay = this.isInteractive ? | ||
@@ -441,17 +481,16 @@ html`<div slot='overlay' class="overlay ${ this.didInteract === false ? 'visible' : '' }"><span class="description"> | ||
return html`<div class="image-sequence ${ this.bufferReady ? '' : 'loading' } ${ this.isInteractive ? 'interactive' : '' }"> | ||
<slot name="loading-overlay"> | ||
<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"> | ||
${ descriptionOverlay } | ||
</slot> | ||
</div>` | ||
return html`<div class="image-sequence ${this.#bufferReady ? '' : 'loading'} ${this.isInteractive ? 'interactive' : ''}"> | ||
<slot name="loading-overlay"> | ||
<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"> | ||
${descriptionOverlay} | ||
</slot> | ||
</div>` | ||
} | ||
} | ||
customElements.define('q-image-sequence',ImageSequence) | ||
customElements.define('q-image-sequence', ImageSequence) |
# q-image-sequence | ||
Provides a simple Lit webcomponent for handling image sequences | ||
Provides a Lit webcomponent for displaying image sequences with a click-and-drag behvaior | ||
## Overview | ||
The component loads a comma-separated list of images passed to the component into a view that moves forward / backward in the sequence of images by dragging in the UI or modifying the `rotate-to-index` or `index` properties. | ||
The component loads a comma-separated list of images passed to the component and allows the user to move forward / backward in the sequence of images by dragging in the UI or modifying the `rotate-to-index` or `index` properties (eg, via a `ref` tag or in the runtime application js). | ||
### Parameters | ||
### Attributes | ||
The component takes a few parameters: | ||
The component takes a few attributes: | ||
- `index` - Index to show in the sequence | ||
- `interactive` - Whether to allow forward/backward user interaction | ||
- `reverse` - Whether to allow reversing in the sequence (ie, left-swipe from index=0) | ||
- `rotate-to-index` - Index to rotate to | ||
- `images` - Comma-separated list of images. *Note that images must use proper URI escaping to pass comma-separated parsing!* | ||
- `images` - Comma-separated list of images. *Note that images must use proper URI escaping to pass comma-separated-value parsing and HTML element attribute escaping!* | ||
### Architecture | ||
The component markup has three parts: | ||
- A loading overlay that is shown when the sequence is buffering | ||
- A call to action overlay shown after load but before interaction that invites click-drag/swipe gestures (called "descriptionOverlay" in the code) | ||
- A canvas element managed via a Lit `ref` | ||
Styles / css are loaded into the Lit template from `styles.js`. | ||
If it is a non-interactive component it loads the first image of the sequence only (though remains responsive to `rotate-to-index` attribute changes). | ||
If the component is interactive, it buffers a percentage of the total images passed at startup and waits for interaction (showing the loading-overlay and overlay as required). | ||
On interaction, the component changes its `index` property, triggering a `drawImage` command to paint the canvas, cancelling the last animation frame drawn and storing the animation frame ID, filling its buffer as necessary. When in-flight requests are made they are deduplicated by storing the Promise returned by `fetch()`, releasing it after the image is buffered. | ||
@@ -29,3 +29,3 @@ import { css } from 'lit' | ||
align-items: center; | ||
position: absolute; | ||
position: relative; | ||
top: 0; | ||
@@ -44,2 +44,3 @@ left: 0; | ||
opacity: 1; | ||
pointer-events: auto; | ||
} | ||
@@ -46,0 +47,0 @@ |
@@ -6,3 +6,3 @@ const filters = require('./filters') | ||
* Add Collections and Apply Transforms | ||
* | ||
* | ||
* Nota bene: The Eleventy API does not make collections data accessible | ||
@@ -23,3 +23,3 @@ * from the plugin context. Adding `collections` and `transforms` sequentially | ||
eleventyConfig.addCollection('allSorted', function (collectionApi) { | ||
return collectionApi.getAll().sort(sortCollection) | ||
return collectionApi.getAllSorted().sort(sortCollection) | ||
}) | ||
@@ -43,3 +43,3 @@ | ||
collections[name] = collectionApi | ||
.getAll() | ||
.getAllSorted() | ||
.filter(filters[name]) | ||
@@ -46,0 +46,0 @@ .sort(sortCollection) |
@@ -15,5 +15,22 @@ # Changelog | ||
## [1.0.0-rc.20] | ||
### Changed | ||
- Refactor image-sequence preloadImages to accept an indices parameter of indices to preload and return the promise of all fetch responses | ||
- Adds an animationIndex property for managing animation when rotating | ||
### Fixed | ||
- image-sequence rotation to an index on load | ||
- display of the rotation call to action | ||
## [1.0.0-rc.19] | ||
### Changed | ||
- Performance improvments and refactoring for image sequences: | ||
- Refactor `q-image-sequence` component to load a buffer of image bitmaps from the image URLs passed to it | ||
- Refactor `q-image-sequence` to use encapsulated styles at the module level | ||
## [1.0.0-rc.18] | ||
@@ -23,3 +40,3 @@ | ||
- Performance improvments for images: | ||
- Performance improvements for images: | ||
- Refactor `figure` subcomponent composition using named `slot` elements for data, ui, slides, and styles | ||
@@ -26,0 +43,0 @@ - Refactor `lightbox` components to generate slides dynamically from JSON data |
{ | ||
"name": "@thegetty/quire-11ty", | ||
"version": "1.0.0-rc.19", | ||
"version": "1.0.0-rc.20", | ||
"description": "Quire 11ty static site generator", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
1031457
28254