diff --git a/config/config.js b/config/config.js index 234fa0db..366f584f 100644 --- a/config/config.js +++ b/config/config.js @@ -17,6 +17,7 @@ module.exports = { 'theme/index.js', 'theme/typography.js', 'utils/eventLogger.js', + 'utils/vignette.js', ], externalDependencies: [ 'date-fns', diff --git a/config/styleguide.config.json b/config/styleguide.config.json index 6fbd1884..2e7f39b0 100644 --- a/config/styleguide.config.json +++ b/config/styleguide.config.json @@ -60,6 +60,8 @@ "HideComponent", "Icon", "IconSprite", + "Image", + "ImagePreloader", "Input", "List", "Select", @@ -69,8 +71,7 @@ "Switch.Item", "Switch.Wrapper", "Timeago", - "VideoPlayIcon", - "Vignette" + "VideoPlayIcon" ] }, { @@ -90,6 +91,15 @@ "useLazyLoad" ] }, + { + "name": "Utils", + "sections": [ + { + "name": "Vignette", + "content": "../source/utils/VIGNETTE.md" + } + ] + }, { "name": "Models", "description": "Wrappers for immutable.js Records.", diff --git a/source/components/Image/README.md b/source/components/Image/README.md new file mode 100644 index 00000000..650bf4a5 --- /dev/null +++ b/source/components/Image/README.md @@ -0,0 +1,20 @@ +Default: + +```js + test +``` + +Disable lazy loading + +```js + test +``` diff --git a/source/components/Image/__snapshots__/index.spec.js.snap b/source/components/Image/__snapshots__/index.spec.js.snap new file mode 100644 index 00000000..60bfa133 --- /dev/null +++ b/source/components/Image/__snapshots__/index.spec.js.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Disabled lazyLoading 1`] = ` +test + test + + +`; + +exports[`Image test 1`] = ` +test + + test + + + +`; + +exports[`Image test for non-vignette 1`] = ` +test + test + +`; + +exports[`Updating an image src 1`] = ` +test + + test + + + +`; diff --git a/source/components/Image/index.js b/source/components/Image/index.js new file mode 100644 index 00000000..a3254254 --- /dev/null +++ b/source/components/Image/index.js @@ -0,0 +1,109 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import ImagePreloader from '../ImagePreloader'; +import { isVignetteUrl, vignette } from '../../utils/vignette'; + +const LAZY_IMAGE_SIZE = 5; + +class Image extends React.Component { + static propTypes = { + alt: PropTypes.string.isRequired, + className: PropTypes.string, + disableLazy: PropTypes.bool, + src: PropTypes.string.isRequired, + srcSet: PropTypes.string, + }; + + static defaultProps = { + className: undefined, + disableLazy: false, + srcSet: undefined, + }; + + state = { + src: this.props.src, + isLimbo: false, + }; + + // When the src changes first replace the src with a temp image so it doesn't stall displaying + // the old image + // https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#when-to-use-derived-state + static getDerivedStateFromProps(props, state) { + // when only the src changes we are in "limbo" mode + return { + isLimbo: props.src !== state.src, + }; + } + + // after the component updates once we want to + componentDidUpdate() { + if (this.props.src !== this.state.src) { + // this is one of the rare cases to conditionally call setState after a component update + // this allows the images to be removed from the DOM properly + // eslint-disable-next-line react/no-did-update-set-state + this.setState(() => ({ src: this.props.src })); + } + } + + /** + * Create a super low resolution image that will automatically be blurred in most browsers + */ + getLowResSrcFromVignette() { + return vignette(this.props.src).withSmart(LAZY_IMAGE_SIZE, LAZY_IMAGE_SIZE).get(); + } + + renderPlainImage() { + const { src, alt, className, disableLazy, ...rest } = this.props; + + return {alt}; + } + + renderVignetteImage() { + const { src: _skip1, srcSet: _skip2, alt, className, disableLazy, ...rest } = this.props; + + if (disableLazy) { + return this.renderPlainImage(); + } + + return ( + + {({ src, srcSet, state }) => { + // we will not test the functionality of ImagePreloader here + /* istanbul ignore next */ + if (state !== ImagePreloader.STATE.PENDING) { + return {alt}; + } + + // if the image is loading, render low quality image + return {alt}; + }} + + ); + } + + render() { + const { src, isLimbo } = this.state; + + if (isVignetteUrl(src)) { + // Limbo state happens when only the src and/or srcset is changed + // there is no standard on how to handle the state of the image when the src is changed across browsers + // lets just remove the entire node from html when in limbo + return ( + + {!isLimbo && this.renderVignetteImage()} + {/* // support no-js and SSR */} + + + ); + } + + // if the image is not a Vignette one, just render it and don't care + return this.renderPlainImage(); + } +} + +export default Image; diff --git a/source/components/Image/index.spec.js b/source/components/Image/index.spec.js new file mode 100644 index 00000000..7a20fa0d --- /dev/null +++ b/source/components/Image/index.spec.js @@ -0,0 +1,43 @@ +import { mount } from 'enzyme'; +import React from 'react'; + +import Image from './index'; + + +// jest.mock('../ImagePreloader', () => mockComponent('ImagePreloader')); + +function mountImage(additionalProps = {}) { + const wrapper = mount( + test, + ); + return wrapper; +} + +test('Image test', () => { + const component = mountImage(); + expect(component).toMatchSnapshot(); +}); + +test('Image test for non-vignette', () => { + const component = mountImage({ src: 'https://foo.jpg' }); + expect(component).toMatchSnapshot(); +}); + +test('Updating an image src', () => { + const component = mountImage(); + + component.setState({ src: 'test' }); + component.update(); + + expect(component).toMatchSnapshot(); +}); + +test('Disabled lazyLoading', () => { + const component = mountImage({ disableLazy: true }); + + expect(component).toMatchSnapshot(); +}); diff --git a/source/components/ImagePreloader/README.md b/source/components/ImagePreloader/README.md new file mode 100644 index 00000000..fd5b5d73 --- /dev/null +++ b/source/components/ImagePreloader/README.md @@ -0,0 +1,30 @@ +`ImagePreloader` component can be used to load (or preload) any image. Both `onChange` and children are functions that are called with `ImagePreloader`'s state with the following: + +* `state` - the current state of the preloading - can be either `pending`, `success` or `error` + All the states are xported via `ImagePreloader.STATE` const. +* `error` - a JavaScript `Error` object (if the `state` is `error`) or null +* `src` - taken from `ImagePreloader`'s props +* `srcSet` - taken from `ImagePreloader`'s props + +### Usage: + +```js static +import ImagePreloader from '@wikia/react-common/components/ImagePreloader'; + +export default ImageWithLoadingMessage = () => ( + + {({ state, src }) => { + if (state === ImagePreloader.STATE.PENDING) { + return Loading image...; + } + + if (state === ImagePreloader.STATE.ERROR) { + return Error loading image; + } + + return ; + }} + +); + +``` diff --git a/source/components/ImagePreloader/image.spec.js b/source/components/ImagePreloader/image.spec.js new file mode 100644 index 00000000..bb0f3ca9 --- /dev/null +++ b/source/components/ImagePreloader/image.spec.js @@ -0,0 +1,78 @@ +import { mount } from 'enzyme'; +import React from 'react'; +import sinon from 'sinon'; +import clone from 'lodash/clone'; + +import ImagePreloader from './index'; + +const image = 'http://vignette.wikia-dev.us/22b12324-ab36-4266-87c9-452776157205'; +const image2 = 'http://foo.jpg'; + +test('ImagePreloader calls children and onChange when state changes', () => { + const childrenFunc = sinon.stub().returns(Foo); + const wrapper = mount( + + {childrenFunc} + + ); + + const defaultState = { + error: null, + src: null, + srcSet: null, + state: ImagePreloader.STATE.PENDING, + }; + const expectedState = clone(defaultState); + + // default render + expect(childrenFunc.callCount).toEqual(1); + expect(childrenFunc.getCall(0).args[0]).toEqual(expectedState); + + // toggle success + wrapper.instance().handleSuccess(); + expectedState.state = ImagePreloader.STATE.SUCCESS; + expectedState.src = image; + + expect(childrenFunc.callCount).toEqual(2); + expect(childrenFunc.getCall(1).args[0]).toEqual(expectedState); + + // toggle error + wrapper.instance().handleError('Foo error'); + expectedState.state = ImagePreloader.STATE.ERROR; + expectedState.error = 'Foo error'; + + expect(childrenFunc.callCount).toEqual(3); + expect(childrenFunc.getCall(2).args[0]).toEqual(expectedState); + + // force props update + wrapper.setProps({ src: image2, srcSet: image2 }); + expectedState.state = ImagePreloader.STATE.PENDING; + + expect(childrenFunc.callCount).toEqual(4); + expect(childrenFunc.getCall(3).args[0]).toEqual(expectedState); + + // toggle success + wrapper.instance().handleSuccess(); + expectedState.state = ImagePreloader.STATE.SUCCESS; + expectedState.src = image2; + + expect(childrenFunc.callCount).toEqual(5); + expect(childrenFunc.getCall(4).args[0]).toEqual(expectedState); + + // force props update (with src=null) + wrapper.setProps({ src: null, srcSet: null }); + expectedState.state = ImagePreloader.STATE.PENDING; + expectedState.src = null; + + expect(childrenFunc.callCount).toEqual(6); + expect(childrenFunc.getCall(5).args[0]).toEqual(expectedState); + + // force unmount, nothing new is called + wrapper.unmount(); + expect(childrenFunc.callCount).toEqual(6); + + // force re-mount, child is called with default state + wrapper.mount(); + expect(childrenFunc.callCount).toEqual(7); + expect(childrenFunc.getCall(6).args[0]).toEqual(defaultState); +}); diff --git a/source/components/ImagePreloader/index.js b/source/components/ImagePreloader/index.js new file mode 100644 index 00000000..2c1d1d6d --- /dev/null +++ b/source/components/ImagePreloader/index.js @@ -0,0 +1,137 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * A helper component that encapsulates image preloading logic. + */ +class ImagePreloader extends React.Component { + static STATE = Object.freeze({ + PENDING: 'pending', + SUCCESS: 'success', + ERROR: 'error', + }); + + static propTypes = { + /** A function that will recieve the state, see below */ + children: PropTypes.func, + /* source for the image */ + src: PropTypes.string, + /** Optional `srcSet` for the image */ + srcSet: PropTypes.string, + }; + + static defaultProps = { + children: null, + src: null, + srcSet: null, + }; + + state = { + src: null, + srcSet: null, + state: ImagePreloader.STATE.PENDING, + error: null, + } + + requestId = null; + + image = null; + + componentDidMount() { + if (this.props.src) { + this.handleStartFetch(this.props.src); + } + } + + static getDerivedStateFromProps(nextProps, prevState) { + return { + src: nextProps.src === null ? null : prevState.src, + srcSet: nextProps.srcSet === null ? null : prevState.srcSet, + state: nextProps.src === prevState.src ? prevState.state : ImagePreloader.STATE.PENDING, + }; + } + + componentDidUpdate(prevProps) { + if (this.props.src === null) { + this.handleStopFetch(); + } + + if ( + this.props.src + && this.props.src !== prevProps.src + && !this.state.state !== ImagePreloader.STATE.PENDING + ) { + this.handleStartFetch(this.props.src, this.props.srcSet); + } + } + + componentWillUnmount() { + this.handleStopFetch(); + } + + handleStartFetch = (src, srcSet) => { + this.handleStopFetch(); + + /* istanbul ignore next */ + this.requestId = requestAnimationFrame(() => { + const image = document.createElement('img'); + + image.onload = this.handleSuccess; + image.onerror = this.handleError; + image.src = src; + image.srcset = srcSet || src; + + if (image.complete) { + this.handleSuccess(); + } + + this.image = image; + }); + }; + + handleStopFetch = () => { + if (this.requestId) { + cancelAnimationFrame(this.requestId); + this.requestId = null; + } + + this.handleImageClear(); + }; + + handleImageClear = () => { + /* istanbul ignore next */ + if (this.image) { + this.image.src = ''; + this.image.srcset = ''; + this.image.onload = null; + this.image.onerror = null; + this.image = null; + } + }; + + handleSuccess = () => { + this.setState({ + state: ImagePreloader.STATE.SUCCESS, + src: this.props.src, + }); + + this.handleStopFetch(); + }; + + handleError = (error) => { + this.setState({ + state: ImagePreloader.STATE.ERROR, + src: this.props.src, + srcSet: this.props.srcSet, + error, + }); + + this.handleStopFetch(); + }; + + render() { + return this.props.children(this.state); + } +} + +export default ImagePreloader; diff --git a/source/components/Vignette/README.md b/source/components/Vignette/README.md deleted file mode 100644 index 72a0cc9b..00000000 --- a/source/components/Vignette/README.md +++ /dev/null @@ -1,32 +0,0 @@ -Default mode: -```js - -``` - -Other methods: -```js -
- - - -
-``` diff --git a/source/components/Vignette/__snapshots__/index.spec.js.snap b/source/components/Vignette/__snapshots__/index.spec.js.snap deleted file mode 100644 index ee79e08a..00000000 --- a/source/components/Vignette/__snapshots__/index.spec.js.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Vignette renders correctly with broken vignette image 1`] = ` - -`; - -exports[`Vignette renders correctly with default values 1`] = ` - -`; - -exports[`Vignette renders correctly with non-vignette image 1`] = ` - -`; diff --git a/source/components/Vignette/helper.js b/source/components/Vignette/helper.js deleted file mode 100644 index e2b7b925..00000000 --- a/source/components/Vignette/helper.js +++ /dev/null @@ -1,55 +0,0 @@ -export function getUuid(urlOrUuid) { - const matches = urlOrUuid.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); - - if (matches) { - // UUID found in `urlOrUuid` - return matches[0]; - } - - return false; -} - -function getVignetteParamsTopCrop(width, height) { - return `/top-crop/width/${width}/height/${height}`; -} - -function getVignetteParamsThumbnail(width, height, allowUpscaling) { - if (allowUpscaling) { - return `/thumbnail/width/${width}/height/${height}`; - } - return `/thumbnail-down/width/${width}/height/${height}`; -} - -function getVignetteParamsScale(width, height, allowUpscaling) { - if (width) { - if (allowUpscaling) { - return `/scale-to-width/${width}`; - } - return `/scale-to-width-down/${width}`; - } - - if (height) { - return `/scale-to-height-down/${height}`; - } - - return ''; -} - -export function getVignetteParams({ - width, height, method, allowUpscaling, -}) { - switch (method) { - case 'top-crop': - return getVignetteParamsTopCrop(width, height); - case 'thumbnail': - return getVignetteParamsThumbnail(width, height, allowUpscaling); - case 'scale': - return getVignetteParamsScale(width, height, allowUpscaling); - default: - // auto - if (width && height) { - return getVignetteParamsThumbnail(width, height, allowUpscaling); - } - return getVignetteParamsScale(width, height, allowUpscaling); - } -} diff --git a/source/components/Vignette/helper.spec.js b/source/components/Vignette/helper.spec.js deleted file mode 100644 index 3550f987..00000000 --- a/source/components/Vignette/helper.spec.js +++ /dev/null @@ -1,98 +0,0 @@ -import * as helper from './helper'; - -test('getUuid returns false for non-uuid urls', () => { - const uuid = helper.getUuid('whatever.jpg'); - - expect(uuid).toBe(false); -}); - -test('getUuid returns uuid from vignette url', () => { - const source = 'https://vignette.wikia.nocookie.net/541b323b-a3c2-4d4f-be1f-500cf13573b8'; - const uuid = helper.getUuid(source); - - expect(uuid).toBe('541b323b-a3c2-4d4f-be1f-500cf13573b8'); -}); - -test('getUuid returns uuid from vignette uuid', () => { - const uuid = helper.getUuid('541b323b-a3c2-4d4f-be1f-500cf13573b8'); - - expect(uuid).toBe('541b323b-a3c2-4d4f-be1f-500cf13573b8'); -}); - -test('getVignetteParams should return proper params for top-crop mode', () => { - const topCrop = helper.getVignetteParams({ - method: 'top-crop', - width: 200, - height: 100, - }); - - expect(topCrop).toBe('/top-crop/width/200/height/100'); -}); - -test('getVignetteParams should return proper params for scale mode', () => { - const params = { method: 'scale' }; - const scaleH = helper.getVignetteParams({ - ...params, - height: 100, - }); - const scaleW = helper.getVignetteParams({ - ...params, - width: 200, - }); - const scaleWUpscaling = helper.getVignetteParams({ - ...params, - width: 200, - allowUpscaling: true, - }); - - expect(scaleH).toBe('/scale-to-height-down/100'); - expect(scaleW).toBe('/scale-to-width-down/200'); - expect(scaleWUpscaling).toBe('/scale-to-width/200'); -}); - -test('getVignetteParams should return proper params for thumbnail mode', () => { - const params = { - method: 'thumbnail', - width: 200, - height: 100, - }; - const thumbnail = helper.getVignetteParams(params); - const thumbnailUpscaling = helper.getVignetteParams({ - ...params, - allowUpscaling: true, - }); - - expect(thumbnail).toBe('/thumbnail-down/width/200/height/100'); - expect(thumbnailUpscaling).toBe('/thumbnail/width/200/height/100'); -}); - -test('getVignetteParams should return proper params for AUTO mode', () => { - // both width and height are present -> thumbnail mode - const params = { - width: 200, - height: 100, - }; - const autoWH = helper.getVignetteParams(params); - const autoWHUpscaling = helper.getVignetteParams({ - ...params, - allowUpscaling: true, - }); - - // something is not present -> scaleWUpscaling mode - const autoH = helper.getVignetteParams({ - height: 100, - }); - const autoW = helper.getVignetteParams({ - width: 200, - }); - const autoWUpscaling = helper.getVignetteParams({ - width: 200, - allowUpscaling: true, - }); - - expect(autoWH).toBe('/thumbnail-down/width/200/height/100'); - expect(autoWHUpscaling).toBe('/thumbnail/width/200/height/100'); - expect(autoH).toBe('/scale-to-height-down/100'); - expect(autoW).toBe('/scale-to-width-down/200'); - expect(autoWUpscaling).toBe('/scale-to-width/200'); -}); diff --git a/source/components/Vignette/index.js b/source/components/Vignette/index.js deleted file mode 100644 index 9d994c3e..00000000 --- a/source/components/Vignette/index.js +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { getVignetteParams, getUuid } from './helper'; - -/** - * Vignette helper for getting scaled/resized images from Static Image Assets service. - * - * Works for any URL (non-vignette ones won't be resized) and for UUIDs. - * The `mode` along with `width`, `height` and '`allowUpscaling` will dictate if - * the final image will be scaled, resized or cropped. - */ -const Vignette = ({ - allowUpscaling, - alt, - className, - height, - image, - method, - width, - ...rest -}) => { - let imageUrlOrUuid = image.replace('//static.wikia.nocookie.net/', '//vignette.wikia.nocookie.net/'); - - if (imageUrlOrUuid.indexOf('vignette.wikia.nocookie.net') !== -1) { - const uuid = getUuid(imageUrlOrUuid); - - if (uuid) { - const params = getVignetteParams({ - width, height, method, allowUpscaling, - }); - imageUrlOrUuid = `https://vignette.wikia.nocookie.net/${uuid}${params}`; - } - } - - return {alt}; -}; - -Vignette.propTypes = { - /** Do we want to upscale image if needed? */ - allowUpscaling: PropTypes.bool, - /** Alt text */ - alt: PropTypes.string, - /** Additional class name */ - className: PropTypes.string, - /** Desired image height */ - height: PropTypes.number, - /** Either an URL to image or UUID. */ - image: PropTypes.string.isRequired, - /** Desired image mode */ - method: PropTypes.oneOf([ - 'auto', - 'scale', - 'thumbnail', - 'top-crop', - ]), - /** Desired image width */ - width: PropTypes.number, -}; - -Vignette.defaultProps = { - allowUpscaling: false, - alt: '', - className: '', - height: null, - method: 'auto', - width: null, -}; - -export default Vignette; diff --git a/source/components/Vignette/index.spec.js b/source/components/Vignette/index.spec.js deleted file mode 100644 index 5f798765..00000000 --- a/source/components/Vignette/index.spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import { shallow } from 'enzyme'; - -import Vignette from './index'; - -test('Vignette renders correctly with default values', () => { - const component = renderer.create( - - ); - expect(component.toJSON()).toMatchSnapshot(); -}); - -test('Vignette renders correctly with non-vignette image', () => { - const component = renderer.create( - - ); - expect(component.toJSON()).toMatchSnapshot(); -}); - -test('Vignette renders correctly with broken vignette image', () => { - const component = renderer.create( - - ); - expect(component.toJSON()).toMatchSnapshot(); -}); - -test('Vignette renders plain image tag when no width and height present', () => { - const image = 'https://vignette.wikia.nocookie.net/541b323b-a3c2-4d4f-be1f-500cf13573b8'; - const component = shallow( - - ); - expect(component.find('img').prop('src')).toEqual(image); -}); diff --git a/source/components/index.js b/source/components/index.js index 23e56ed5..b9255dd4 100644 --- a/source/components/index.js +++ b/source/components/index.js @@ -4,41 +4,35 @@ * importable. */ -// Design System Elements +// Components +export { default as Avatar } from './Avatar'; +export { default as AvatarStack } from './AvatarStack'; +export { default as BannerNotification } from './BannerNotification'; +export { default as BannerNotifications } from './BannerNotifications'; export { default as Button } from './Button'; export { default as ButtonGroup } from './ButtonGroup'; export { default as Checkbox } from './Checkbox'; -export { default as Input } from './Input'; -export { default as Fieldset } from './Fieldset'; -export { default as Spinner } from './Spinner'; +export { default as ContentWell } from './ContentWell'; export { default as Dropdown } from './Dropdown'; +export { default as ExpandableText } from './ExpandableText'; +export { default as FandomBackgroundImage } from './FandomBackgroundImage'; +export { default as FandomContentWell } from './FandomContentWell'; +export { default as Fieldset } from './Fieldset'; export { default as FloatingButton } from './FloatingButton'; export { default as FloatingButtonGroup } from './FloatingButtonGroup'; -// Design System UI -export { default as BannerNotification } from './BannerNotification'; -export { default as BannerNotifications } from './BannerNotifications'; export { default as GlobalNavigation } from './GlobalNavigation'; - -// Lite Icons -export { default as VideoPlayIcon } from './VideoPlayIcon'; -// Useful flow components -export { default as ContentWell } from './ContentWell'; -export { default as FandomContentWell } from './FandomContentWell'; -export { default as List } from './List'; -// Other UI -export { default as Avatar } from './Avatar'; -export { default as AvatarStack } from './AvatarStack'; -export { default as ExpandableText } from './ExpandableText'; export { default as HideComponent } from './HideComponent'; +export { default as Icon } from './Icon'; +export { default as IconSprite } from './IconSprite'; +export { default as Image } from './Image'; +export { default as ImagePreloader } from './ImagePreloader'; +export { default as Input } from './Input'; +export { default as List } from './List'; +export { default as SimpleLocalNavigation } from './SimpleLocalNavigation'; +export { default as Spinner } from './Spinner'; export { default as Switch } from './Switch'; export { default as Timeago } from './Timeago'; -export { default as SimpleLocalNavigation } from './SimpleLocalNavigation'; -export { default as Vignette } from './Vignette'; +export { default as VideoPlayIcon } from './VideoPlayIcon'; // custom types -export { default as bannerNotificationsMessageType } - from './BannerNotifications/bannerNotificationsMessageType'; - -// HEAVY icons -export { default as IconSprite } from './IconSprite'; -export { default as Icon } from './Icon'; +export { default as bannerNotificationsMessageType } from './BannerNotifications/bannerNotificationsMessageType'; diff --git a/source/utils/VIGNETTE.md b/source/utils/VIGNETTE.md new file mode 100644 index 00000000..a3ccc4f3 --- /dev/null +++ b/source/utils/VIGNETTE.md @@ -0,0 +1,79 @@ +The `utils/vignette.js` offers few utilities that can be used to deal with Vignette images - most importantly the validation and manipulation of Vignette's URLs. + +## Validation + +There are two functions can be used to validate a proper Vignette ID and URL: + +* `isVignetteUrl(url: String): Boolean` +* `isVignetteId(id: String): Boolean` +* `vignette(imageUrl): VignetteHelper` + +## `VignetteHelper` class + +This class can be used to extract and manipulate URLs - it can extract Vignette params from given image and new options can be applied. +Class' instance keeps the base image URL (server + ID) as well as all the Vignette params. + +**NOTE**: The `vignette(url)` function can be used instead of `new VignetteHelper(url)`. + +### API + +All the following are instance methods: + +* `isOk(): Boolean` - returns true if the image has been correctly set +* `resetParams(): void` - resets Vignette params +* `set(url: String): Boolean` - extracts base image URL and params from Vignette URL; returns false if the URL is not a Vignette one +* `getParams(): String` - returns the Vigentte params +* `get(): String` - returns full Vignette URL +* `clone(): VignetteHelper` - returns clone of the instance +* `withScaleToHeight(height: Number, allowUpscaling: Boolean = false): void` - returns new instance with changed Vignette params +* `withScaleToWidth(width: Number, allowUpscaling: Boolean = false): void` - returns new instance with changed Vignette params +* `withThumbnail(width: Number, height: Number, allowUpscaling: Boolean = false): void` - returns new instance with changed Vignette params +* `withTopCrop(width: Number, height: Number): void` - returns new instance with changed Vignette params +* `withSmart(width: Number, height: Number): void` - returns new instance with changed Vignette params +* `withAuto(width: Number, height: Number, allowUpscaling: Boolean = false): void` - returns new instance with changed Vignette params +* `withTransform(): VignetteHelper` - returns clone of the instance with changed Vignette params + +Usage: + +```js static +import { VignetteHelper } from '@wikia/react-common/utils/vignette'; + +const ThumbImage = ({ alt, src, width, height }) => { + const image = new VignetteHelper(src); + const image300px = image.withScaleToWidth(300).get(); + + const srcSet = ` + ${image300px} 300w + ${image.withScaleToWidth(600).get()} 600w + ${image.withScaleToWidth(1000).get()} 2x + `; + + return {alt}; +} + +// this will be the same as + +import { VignetteHelper } from '@wikia/react-common/utils/vignette'; + +const ThumbImage = ({ alt, src, width, height }) => { + const standardImage = vignette(src); + + const srcSet = ` + ${standardImage.withScaleToWidth(300).get()} 300w + ${standardImage.withScaleToWidth(600).get()} 600w + ${standardImage.withScaleToWidth(1000).get()} 2x + `; + + return {alt}; +} +``` + +## More + +Additionally, there are few other exports that are used internally, but might be useful: + +* `VIGNETTE_DEFAULT_SERVER` +* `VIGNETTE_MODES` +* `VIGNETTE_UUID_REGEX` +* `VIGNETTE_SERVER_REGEX` +* `VIGNETTE_BASE_IMAGE_REGEX` diff --git a/source/utils/vignette.js b/source/utils/vignette.js new file mode 100644 index 00000000..1010dd1a --- /dev/null +++ b/source/utils/vignette.js @@ -0,0 +1,201 @@ +export const VIGNETTE_MODES = Object.freeze({ + auto: 'auto', + scale: 'scale', + smart: 'smart', + thumbnail: 'thumbnail', + topCrop: 'top-crop', +}); +export const VIGNETTE_DEFAULT_SERVER = 'https://static.wikia.nocookie.net/'; + +export const VIGNETTE_UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/; +export const VIGNETTE_SERVER_REGEX = /^https?:\/\/(vignette|static)\.wikia(-dev)?\.(pl|us|com|nocookie\.net)\//; +const VIGNETTE_BASE_IMAGE_REGEX = new RegExp(VIGNETTE_SERVER_REGEX.source + VIGNETTE_UUID_REGEX.source); + +export function isVignetteUrl(imageUrl) { + return VIGNETTE_BASE_IMAGE_REGEX.test(imageUrl); +} + +export function isVignetteId(id) { + return VIGNETTE_UUID_REGEX.test(id); +} + +/** + * Helper factory function + */ +export function vignette(imageUrl) { + return new VignetteHelper(imageUrl); +} + +export class VignetteHelper { + constructor(imageUrl) { + this.baseImage = undefined; + + this.set(imageUrl); + } + + isOk() { + return !!this.baseImage; + } + + resetParams() { + this.allowUpscaling = false; + this.height = undefined; + this.mode = undefined; + this.width = undefined; + } + + set(imageUrl) { + this.resetParams(); + + if (isVignetteUrl(imageUrl)) { + const matches = imageUrl.match(VIGNETTE_BASE_IMAGE_REGEX); + this.baseImage = matches[0]; // eslint-disable-line prefer-destructuring + + const paramsStr = imageUrl.substring(this.baseImage.length + 1); + + // if `imageUrl` has some params, grab them - basically revert `::getParams` + if (paramsStr) { + const params = paramsStr.split('/'); + switch (params[0]) { + case 'smart': + // /smart/width/123/height/123 + this.mode = VIGNETTE_MODES.smart; + this.height = params[4]; // eslint-disable-line prefer-destructuring + this.width = params[2]; // eslint-disable-line prefer-destructuring + break; + + case 'top-crop': + // /top-crop/width/123/height/123 + this.mode = VIGNETTE_MODES.topCrop; + this.height = params[4]; // eslint-disable-line prefer-destructuring + this.width = params[2]; // eslint-disable-line prefer-destructuring + break; + + case 'thumbnail': + // /thumbnail/width/123/height/123 + this.mode = VIGNETTE_MODES.thumbnail; + this.allowUpscaling = true; + this.height = params[4]; // eslint-disable-line prefer-destructuring + this.width = params[2]; // eslint-disable-line prefer-destructuring + break; + + case 'thumbnail-down': + // /thumbnail-down/width/123/height/123 + this.mode = VIGNETTE_MODES.thumbnail; + this.height = params[4]; // eslint-disable-line prefer-destructuring + this.width = params[2]; // eslint-disable-line prefer-destructuring + break; + + case 'scale-to-width': + // /scale-to-width/1234 + this.mode = VIGNETTE_MODES.scale; + this.allowUpscaling = true; + this.width = params[1]; // eslint-disable-line prefer-destructuring + break; + + case 'scale-to-width-down': + // /scale-to-width-down/1234 + this.mode = VIGNETTE_MODES.scale; + this.width = params[1]; // eslint-disable-line prefer-destructuring + break; + + case 'scale-to-height-down': + // /scale-to-height/1234 + this.mode = VIGNETTE_MODES.scale; + this.height = params[1]; // eslint-disable-line prefer-destructuring + break; + + default: + console.error(`Unknown vignette mode: ${params[0]}, ignoring`); + } + } + + return true; + } + + this.baseImage = undefined; + return false; + } + + getParams() { + const { height, width, allowUpscaling } = this; + let { mode } = this; + + if (mode === VIGNETTE_MODES.auto) { + mode = (width && height) ? VIGNETTE_MODES.thumbnail : VIGNETTE_MODES.scale; + } + + switch (mode) { + case VIGNETTE_MODES.smart: + return `/smart/width/${width}/height/${height}`; + + case VIGNETTE_MODES.topCrop: + return `/top-crop/width/${width}/height/${height}`; + + case VIGNETTE_MODES.thumbnail: + if (allowUpscaling) { + return `/thumbnail/width/${width}/height/${height}`; + } + return `/thumbnail-down/width/${width}/height/${height}`; + + case VIGNETTE_MODES.scale: + if (width) { + if (allowUpscaling) { + return `/scale-to-width/${width}`; + } + return `/scale-to-width-down/${width}`; + } + + if (height) { + return `/scale-to-height-down/${height}`; + } + return ''; + + default: + return ''; + } + } + + get() { + return `${this.baseImage}${this.getParams()}`; + } + + clone() { + return Object.assign(Object.create(Object.getPrototypeOf(this)), this); + } + + withTransform(kv) { + const myClone = this.clone(); + myClone.resetParams(); + + Object.keys(kv).forEach((key) => { + myClone[key] = kv[key]; + }); + + return myClone; + } + + withScaleToHeight(height, allowUpscaling = false) { + return this.withTransform({ mode: VIGNETTE_MODES.scale, width: undefined, height, allowUpscaling }); + } + + withScaleToWidth(width, allowUpscaling = false) { + return this.withTransform({ mode: VIGNETTE_MODES.scale, height: undefined, width, allowUpscaling }); + } + + withThumbnail(width, height, allowUpscaling = false) { + return this.withTransform({ mode: VIGNETTE_MODES.thumbnail, width, height, allowUpscaling }); + } + + withTopCrop(width, height) { + return this.withTransform({ mode: VIGNETTE_MODES.topCrop, width, height }); + } + + withSmart(width, height) { + return this.withTransform({ mode: VIGNETTE_MODES.smart, width, height }); + } + + withAuto(width = undefined, height = undefined, allowUpscaling = false) { + return this.withTransform({ mode: VIGNETTE_MODES.auto, width, height, allowUpscaling }); + } +} diff --git a/source/utils/vignette.spec.js b/source/utils/vignette.spec.js new file mode 100644 index 00000000..734a49e3 --- /dev/null +++ b/source/utils/vignette.spec.js @@ -0,0 +1,102 @@ +import { isVignetteUrl, isVignetteId, VignetteHelper, vignette } from './vignette'; + +const DEFAULT_BASE_IMAGE = 'https://vignette.wikia.com/541b323b-a3c2-4d4f-be1f-500cf13573b8'; + +describe('isVignetteUrl works properly', () => { + const testData = { + 'https://vignette.wikia.nocookie.net/541b323b-a3c2-4d4f-be1f-500cf13573b8/smart/width/5/height/5': true, + 'https://vignette.wikia.com/541b323b-a3c2-4d4f-be1f-500cf13573b8': true, + 'http://vignette.wikia-dev.pl/541b323b-a3c2-4d4f-be1f-500cf13573b8': true, + 'http://static.wikia-dev.us/541b323b-a3c2-4d4f-be1f-500cf13573b8': true, + 'http://static.wikia-dev.us/not-an-id': false, + 'http://static.wikia-dev.us/': false, + 'https://wikia.com/541b323b-a3c2-4d4f-be1f-500cf13573b8': false, + 'https://static.wikia.com/541b323b-xxxx-4d4f-be1f-500cf13573b8': false, + '': false, + }; + + Object.keys(testData).forEach((key) => { + test(`isVignetteUrl: "${key}" => ${testData[key]}`, () => { + expect(isVignetteUrl(key)).toBe(testData[key]); + }); + }); +}); + +describe('isVignetteId works properly', () => { + const testData = { + '541b323b-a3c2-4d4f-be1f-500cf13573b8': true, + 'some-random-string': false, + '': false, + }; + + Object.keys(testData).forEach((key) => { + test(`isVignetteId: "${key}" => ${testData[key]}`, () => { + expect(isVignetteId(key)).toBe(testData[key]); + }); + }); +}); + +test('VignetteHelper can be initialized', () => { + const helperWithoutParams = vignette(DEFAULT_BASE_IMAGE); + + expect(helperWithoutParams.baseImage).toEqual(DEFAULT_BASE_IMAGE); + expect(helperWithoutParams.getParams()).toEqual(''); + + const helperWithParams = new VignetteHelper(`${DEFAULT_BASE_IMAGE}/smart/width/5/height/5`); + expect(helperWithParams.baseImage).toEqual(DEFAULT_BASE_IMAGE); + expect(helperWithParams.getParams()).toEqual('/smart/width/5/height/5'); +}); + +test('VignetteHelper can be cloned', () => { + const helperOne = new VignetteHelper(DEFAULT_BASE_IMAGE); + const helperOneParams = helperOne.getParams(); + + expect(helperOne.clone().withSmart(20, 10).getParams()).toEqual('/smart/width/20/height/10'); + expect(helperOne.getParams()).toEqual(helperOneParams); +}); + +test('VignetteHelper can handle all the different modes', () => { + const allModes = [ + '/smart/width/5/height/5', + '/top-crop/width/1000/height/5', + '/thumbnail-down/width/20/height/30', + '/thumbnail/width/10/height/20', + '/thumbnail-down/width/20/height/30', + '/scale-to-width/100', + '/scale-to-width-down/200', + '/scale-to-height-down/50', + ]; + + const helper = new VignetteHelper(DEFAULT_BASE_IMAGE); + + allModes.forEach((mode) => { + helper.set(`${DEFAULT_BASE_IMAGE}${mode}`); + + expect(helper.getParams()).toEqual(mode); + }); +}); + +test('VignetteHelper ignores bad method', () => { + const helper = new VignetteHelper(DEFAULT_BASE_IMAGE); + + expect(helper.withTransform({ method: 'foo' }).getParams()).toEqual(''); +}); + +test('VignetteHelper can handle all the with* methods', () => { + const helper = new VignetteHelper(DEFAULT_BASE_IMAGE); + + expect(helper.withScaleToHeight(10).getParams()).toEqual('/scale-to-height-down/10'); + expect(helper.withScaleToWidth(20).getParams()).toEqual('/scale-to-width-down/20'); + expect(helper.withScaleToWidth(30, true).getParams()).toEqual('/scale-to-width/30'); + + expect(helper.withThumbnail(150, 100).getParams()).toEqual('/thumbnail-down/width/150/height/100'); + expect(helper.withThumbnail(250, 200, true).getParams()).toEqual('/thumbnail/width/250/height/200'); + + expect(helper.withTopCrop(100, 10).getParams()).toEqual('/top-crop/width/100/height/10'); + expect(helper.withSmart(5, 6).getParams()).toEqual('/smart/width/5/height/6'); + + // auto's special case because it's either scale (if there's only width or height present) or thumbnail (when there are both) + expect(helper.withAuto(200).getParams()).toEqual('/scale-to-width-down/200'); + expect(helper.withAuto(undefined, 100).getParams()).toEqual('/scale-to-height-down/100'); + expect(helper.withAuto(200, 100).getParams()).toEqual('/thumbnail-down/width/200/height/100'); +});