Skip to content

Instantly share code, notes, and snippets.

@addisonElliott
Created May 27, 2019 15:57
Show Gist options
  • Save addisonElliott/3a77cc2052a90b9b6d3a097755675b1b to your computer and use it in GitHub Desktop.
Save addisonElliott/3a77cc2052a90b9b6d3a097755675b1b to your computer and use it in GitHub Desktop.
x({
src: './PATS/PATS_1-%ww.%e',
exts: ['png', 'webp'],
widths: [420, 370, 298, 277, 223, 168, 139, 105],
sizes: '177px', // 250px * 0.710 aspect ratio
aspectRatio: 0.71,
alt: 'Splash screen / logo'
}),
import React, { Component } from 'react';
import {
Card,
Row,
Col,
CardHeader,
CardTitle,
CardSubtitle,
CardText,
CardBody,
Nav,
NavItem,
Badge,
NavLink
} from 'reactstrap';
import Lightbox from 'react-images';
import NewTargetLink from './utils';
import { loadResponsiveImages, createSrcSetString, ResponsiveImage } from './responsiveImage';
class Project extends Component {
constructor(props) {
super(props);
this.state = {
currentLightboxImage: 0,
lightboxIsOpen: false
};
// Bind components
this.showLightbox = this.showLightbox.bind(this);
this.hideLightbox = this.hideLightbox.bind(this);
this.gotoPrevious = this.gotoPrevious.bind(this);
this.gotoNext = this.gotoNext.bind(this);
this.gotoImage = this.gotoImage.bind(this);
this.handleClickImage = this.handleClickImage.bind(this);
this.customImage = this.customImage.bind(this);
this.customPreloadImage = this.customPreloadImage.bind(this);
}
showLightbox(index, event) {
if (event) {
event.preventDefault();
}
this.setState({
currentLightboxImage: index,
lightboxIsOpen: true
});
}
hideLightbox() {
this.setState({
lightboxIsOpen: false
});
}
gotoPrevious() {
this.setState({
currentLightboxImage: this.state.currentLightboxImage - 1
});
}
gotoNext() {
this.setState({
currentLightboxImage: this.state.currentLightboxImage + 1
});
}
gotoImage(index) {
this.setState({
currentLightboxImage: index
});
}
handleClickImage() {
if (this.state.currentLightboxImage === this.props.images.length - 1) return;
this.gotoNext();
}
/**
* Calculates Lightbox's image sizes attribute used for determining appropriate image to choose from srcset attribute
*
* Uses width & height offset (i.e. padding) for the Lightbox and properties of the image (e.g. aspect ratio) to calculate the sizes
*
* This is used for the image preload handler and the image handler itself
*
* @param {object} image
* @param {int} widthOffset
* @param {int} heightOffset
*/
calculateSizes(image, widthOffset, heightOffset) {
// No need for sizes if there is only one width (or no width specified)
// Note: Return an empty string and not null or undefined because Image.sizes (and src) will convert to a string and thus it'll try to load "undefined" or
// "null" rather than doing nothing
if (!image.widths || image.widths.length === 1) return '';
// Maximum width of an image, setting is set in lightbox and 1024px is the default value
// If the image width itself is smaller than the maximum value, then we cap it at that otherwise the image will be stretched to the max
// size and will look blurry.
// Note: This assumes the largest image width will be specified first, a fair assumption/requirement to make
const maxWidth = Math.min(1024, image.widths[0]);
// Size for screen at which the maximum image size will be reached (width = maxWidth)
// For the width, this is simply the maximum image width plus the horizontal padding (i.e. offset)
const screenMaxWidth = maxWidth + widthOffset;
// For the height, this finds the maximum height by taking maximum image width divided by image aspect ratio and then adding the vertical padding
const screenMaxHeight = Math.round(maxWidth / image.aspectRatio + heightOffset);
// Effective aspect ratio for displaying an image, the width/height offsets are subtracted from the width/height
// Note: Forced to use document.documentElement.clientWidth because it contains the actual width of the view, while window.screen.width and similar
// variants contains the device width regardless of the orientation of the device.
// const lightboxAspectRatio = (window.screen.availWidth - widthOffset) / (window.screen.availHeight - heightOffset);
const lightboxAspectRatio =
(document.documentElement.clientWidth - widthOffset) / (document.documentElement.clientHeight - heightOffset);
// If the lightbox aspect ratio is smaller than the image aspect ratio, then that means the width is constraining the image
// Otherwise the height is constraining the image
// Note: This is NOT apart of the media query because CSS does not support decimal display ratios, they only have predetermined ratios like 16/9, 4/3, etc
// My initial gut reaction that this solution isn't as versatile because if the user changes the browser window size or somehow it changes, then this function
// would not be called again and the image may be sized wrong. But, when testing, I noticed that this function is called whenever the browser is resized so there
// is minimal issues with using this method.
//
// Note2: Another note is that iOS 9.3.5 (Chrome, Firefox & Safari) do not support multiple queries in the sizes attribute. Meaning in one media query, you cannot
// put and/or between two statements. Originally had a min-width and min-height statement but since we know if the image is width or height constrained based on the
// if statement, it's unnecessary
let sizes;
if (lightboxAspectRatio < image.aspectRatio) {
sizes = `(min-width: ${screenMaxWidth}px) ${maxWidth}px,
calc(100vw - ${widthOffset}px)`;
} else {
sizes = `(min-height: ${screenMaxHeight}px) ${maxWidth}px,
calc(${image.aspectRatio} * (100vh - ${heightOffset}px))`;
}
return sizes;
}
customImage(image, imageLoaded, figureClassName, imgClassName, onClickImage, widthOffset, heightOffset) {
// If the image is a video (best way to show animation, GIFs are terrible), then display a video frame
if (image.isVideo) {
const webmVideo = image.exts.includes('webm')
? imagesContext(image.src.replace(new RegExp('%e', 'g'), 'webm'))
: null;
const mp4Video = image.exts.includes('mp4')
? imagesContext(image.src.replace(new RegExp('%e', 'g'), 'mp4'))
: null;
const oggVideo = image.exts.includes('ogg')
? imagesContext(image.src.replace(new RegExp('%e', 'g'), 'ogg'))
: null;
return (
<video className={imgClassName} autoPlay controls poster={image.poster}>
{webmVideo && <source src={webmVideo} type="video/webm" />}
{mp4Video && <source src={mp4Video} type="video/mp4" />}
{oggVideo && <source src={oggVideo} type="video/ogg" />}
Your browser does not support the video tag.
</video>
);
}
// Calculate the sizes attribute
const sizes = this.calculateSizes(image, widthOffset, heightOffset);
return (
<figure className={figureClassName}>
<ResponsiveImage
context={imagesContext}
src={image.src}
exts={image.exts}
widths={image.widths}
sizes={sizes}
alt={image.alt}
className={imgClassName}
style={{
cursor: onClickImage ? 'pointer' : 'auto',
maxHeight: `calc(100vh - ${heightOffset}px)`
}}
onClick={onClickImage}
/>
</figure>
);
}
customPreloadImage(imageData, onload, widthOffset, heightOffset) {
const image = new Image();
image.onerror = onload;
image.onload = onload;
// Do nothing for a video, just load an empty src so it will call the onload callback
if (imageData.isVideo) {
image.src = '';
return image;
}
// TODO Look into using better video options rather than GIF
// TODO Add placeholder/blurred image support for profile picture and potentially background image
// Calculate the sizes attribute, will return null if no widths are given meaning there is only one sized image to load
image.sizes = this.calculateSizes(imageData, widthOffset, heightOffset);
// Get the extension to load, use WebP if browser supports it and available, otherwise use the default
// In the case that no extensions are given, then give a blank string and loadResponsiveImages will work fine by not doing anything about the extension
let ext = '';
if (imageData.exts) {
// Confusing bit of logic to basically get the appropriate extension to load for the image
// webpIndex is the index of the webp extension with it being -1 if it does not exist for this image
const webpIndex = imageData.exts.indexOf('webp');
// Index for normal image format, basically the opposite of the webpIndex
const normalIndex = webpIndex === 0 ? 1 : 0;
// The actual index used will be 0 if there is no WebP index OR if there is only one extension available (doesnt matter if its WebP or not, we just load that)
// Otherwise, if the brwoser supports WebP, use the webpIndex, otherwise use the normal index
const index =
webpIndex === -1 || imageData.exts.length === 1 ? 0 : document.supportsWebP ? webpIndex : normalIndex;
ext = imageData.exts[index];
}
// Load the image paths based on the src, desired extension & the widths
const images = loadResponsiveImages(imagesContext, imageData.src, ext, imageData.widths);
// If there is more than one size image, then create a srcset string, otherwise set to undefined
image.srcset = image.sizes ? createSrcSetString(images, imageData.widths) : '';
// Used for IE and other browsers that do not support the srcset attribute, this forces the callback to be called immediately, effectively disabling preloading
// If there is only one image, then set the src to be the only image src
image.src = image.sizes ? '' : images[0];
return image;
}
render() {
const image = this.props.images !== undefined ? this.props.images[0] : undefined;
return (
<Col
xs="12"
lg="6"
className={
this.props.filters
? this.props.filters.concat(['px-smx-d-0', 'project-all']).join(' ')
: 'px-smx-d-0 project-all'
}
>
<Card>
{image !== undefined && (
<div
className="card-image-container"
onClick={event => {
this.showLightbox(0, event);
}}
>
<ResponsiveImage
context={imagesContext}
src={image.src}
exts={image.exts}
widths={image.widths}
sizes={image.sizes}
alt={image.alt}
className="card-img-top"
onClick={event => {
this.showLightbox(0, event);
}}
/>
</div>
)}
<CardBody>
<Row>
<Col xs="12" className="col-smxx-10 pr-0 m-0">
<CardTitle>
<NewTargetLink href={this.props.websiteLink || this.props.githubLink}>
{this.props.title}
</NewTargetLink>
</CardTitle>
</Col>
<Col xs="12" className="col-smxx-2 pl-smxx-0 m-0 text-smxx-right">
{this.props.websiteLink && (
<NewTargetLink href={this.props.websiteLink}>
<FontAwesomeIcon icon="globe-americas" size="2x" />
</NewTargetLink>
)}
{this.props.githubLink && (
<NewTargetLink href={this.props.githubLink}>
<FontAwesomeIcon icon={['fab', 'github']} size="2x" />
</NewTargetLink>
)}
</Col>
</Row>
<CardSubtitle>
<ul className="technology-list">
{this.props.technologies.map((language, index) => {
return (
<li key={index}>
<Badge color="custom" pill>
{language}
</Badge>
</li>
);
})}
</ul>
</CardSubtitle>
<CardText tag="div" className="card-desc">
{this.props.children}
</CardText>
{this.props.images !== undefined && (
<Lightbox
currentImage={this.state.currentLightboxImage}
images={this.props.images}
isOpen={this.state.lightboxIsOpen}
onClickImage={this.handleClickImage}
onClickNext={this.gotoNext}
onClickPrev={this.gotoPrevious}
onClose={this.hideLightbox}
customImage={this.customImage}
customPreloadImage={this.customPreloadImage}
preloadNextImage={true}
showThumbnails={false}
backdropClosesModal
/>
)}
</CardBody>
</Card>
</Col>
);
}
}
class Projects extends Component {
constructor(props) {
super(props);
}
render() {
return (
<Row id="projects" className="section">
<Col>
<Card className="minimalist">
<CardHeader>Projects</CardHeader>
<CardBody>
<Row className="projects-row">
... <Content Excluded>
<Project>
...
</Project>
</Row>
</CardBody>
</Card>
</Col>
</Row>
);
}
}
export default Projects;
/* eslint-disable jsx-a11y/alt-text */
import React, { PureComponent } from 'react';
/**
* Loads all size image paths for a responsive image using a WebPack context.
*
* This function uses a WebPack context along with a source template path that will be replaced with given widths that the responsive image
* is sized at. In addition, multiple file types can be specified in the source template path, although only one file type can be loaded using
* this function at once.
*
* @param {object} context Webpack require.context used for dynamically loading the responsive image
* @param {string} src Source template path for the responsive image to be loaded from the context. Extension for the source can be denoted
* with %e and widths with %w
* @param {string} ext Image file extension (without the period at beginning) for the responsive image
* @param {int[]} widths A list of widths, in pixels, to load for the responsive image
*
* @returns {string[]} A list of image paths for the responsive image
*/
function loadResponsiveImages(context, src, ext, widths) {
// Get the path with %e replaced with the extension
const path1 = src.replace(new RegExp('%e', 'g'), ext);
// If no widths are given, then just load the src path itself
if (!widths) {
return [context(src)];
}
return widths.map(width => {
// Replace all occurrences of %w with the width to get the actual path
const path = path1.replace(new RegExp('%w', 'g'), width);
// Load the actual path by calling require, essentially offloads it to webpack
return context(path);
});
}
/**
* Creates a srcset string to be used in an img tag for the responsive image.
*
* This takes in a list of image paths and widths to create a srcset string.
*
* @param {string[]} images List of image paths for the responsive image
* @param {int[]} widths List of widths, in pixels, representing the various sizes for the responsive image
*
* @return {string} A string that is compatible with the img srcset tag denoting image path and corresponding width it has
*/
function createSrcSetString(images, widths) {
return images
.map((image, index) => {
const width = widths[index];
return `${image} ${width}w`;
})
.join(', ');
}
// PureComponent only updates when props change
// This is used because calling getImages in render could be computationally expensive and this will only call render when a prop changes
class ResponsiveImage extends PureComponent {
getImages(context, src, exts, widths, sizes) {
let images = null;
let webpImages = null;
// Matches two cases, if no widths given, then throw an error if %w is in string
// Or, if widths are given and there is no %w in string, throw error
if (!widths === src.includes('%w')) {
throw TypeError('Invalid properties for ResponsiveImage, %w must be present if widths are given');
}
// Matches two cases, if no exts given, then throw an error if %e is in string
// Or, if exts are given and there is no %e in string, throw error
if (!exts === src.includes('%e')) {
throw TypeError('Invalid properties for ResponsiveImage, %e must be present if exts are given');
}
if (!exts) {
images = loadResponsiveImages(context, src, null, widths);
} else {
this.props.exts.forEach(ext => {
const images_ = loadResponsiveImages(context, src, ext, widths);
// If ext is webp, do that stuff
// If extension is webp, save those in a different spot as the normal images
if (ext === 'webp') {
webpImages = images_;
} else {
images = images_;
}
});
if (!images) {
throw TypeError('Invalid properties for ResponsiveImage, at least one normal image is required');
}
}
return { images, webpImages };
}
render() {
// Use object destructuring to retrieve all props to pass along to img tag
let { context, src, exts, widths, sizes, ...imageProps } = this.props;
// Get image path based on props
// Note: Since this is a PureComponent, render is only called when a prop changes, we can assume the images will be different
let { images, webpImages } = this.getImages(context, src, exts, widths, sizes);
// Dont bother with picture tag if no webp
if (!webpImages) {
return (
<img
// Only display srcset & sizes if there is more than 1 image, otherwise just use src
srcSet={images.length !== 1 ? createSrcSetString(images, widths) : undefined}
sizes={images.length !== 1 ? sizes : undefined}
src={images.length === 1 ? images[0] : undefined}
{...imageProps}
/>
);
} else {
return (
<picture>
<source
// If there is only one WebP image, meaning no responsive images are given, then just use the srcset attribute and treat it like the src attribute
// That is what many examples show and what spec uses, no src tag in source element
srcSet={webpImages.length !== 1 ? createSrcSetString(webpImages, widths) : webpImages[0]}
sizes={webpImages.length !== 1 ? sizes : undefined}
type="image/webp"
/>
<img
// Only display srcset & sizes if there is more than 1 image, otherwise just use src
srcSet={images.length !== 1 ? createSrcSetString(images, widths) : undefined}
sizes={images.length !== 1 ? sizes : undefined}
src={images.length === 1 ? images[0] : undefined}
{...imageProps}
/>
</picture>
);
}
}
}
export { loadResponsiveImages, createSrcSetString, ResponsiveImage };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment