Created
May 27, 2019 15:57
-
-
Save addisonElliott/3a77cc2052a90b9b6d3a097755675b1b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | |
}), |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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