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
+
+```
+
+Disable lazy loading
+
+```js
+
+```
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`] = `
+
+
+
+
+`;
+
+exports[`Image test 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`Image test for non-vignette 1`] = `
+
+
+
+`;
+
+exports[`Updating an image src 1`] = `
+
+
+
+
+
+
+`;
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
;
+ }
+
+ 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
;
+ }
+
+ // if the image is loading, render low quality image
+ return
;
+ }}
+
+ );
+ }
+
+ 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(
+ ,
+ );
+ 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
;
-};
-
-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
;
+}
+
+// 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
;
+}
+```
+
+## 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');
+});