mirror of
https://github.com/facebook/docusaurus.git
synced 2025-08-01 07:49:43 +02:00
refactor(ideal-image-plugin): internalize legacy component code (#11010)
* almost working * refactor: apply lint autofix * cspell ignore * refactor: apply lint autofix * type fixes --------- Co-authored-by: slorber <749374+slorber@users.noreply.github.com>
This commit is contained in:
parent
502b9007be
commit
fcee060f40
33 changed files with 1717 additions and 133 deletions
|
@ -23,6 +23,7 @@
|
|||
"CHANGELOG.md",
|
||||
"patches",
|
||||
"packages/docusaurus-theme-translations/locales",
|
||||
"packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy",
|
||||
"package.json",
|
||||
"yarn.lock",
|
||||
"project-words.txt",
|
||||
|
|
|
@ -22,3 +22,6 @@ packages/create-docusaurus/templates/facebook
|
|||
|
||||
website/_dogfooding/_swizzle_theme_tests
|
||||
website/_dogfooding/_asset-tests/badSyntax.js
|
||||
|
||||
|
||||
packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
"@docusaurus/theme-translations": "3.7.0",
|
||||
"@docusaurus/types": "3.7.0",
|
||||
"@docusaurus/utils-validation": "3.7.0",
|
||||
"@slorber/react-ideal-image": "^0.0.14",
|
||||
"react-waypoint": "^10.3.0",
|
||||
"sharp": "^0.32.3",
|
||||
"tslib": "^2.6.0",
|
||||
|
|
124
packages/docusaurus-plugin-ideal-image/src/deps.d.ts
vendored
124
packages/docusaurus-plugin-ideal-image/src/deps.d.ts
vendored
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/// <reference types="@docusaurus/module-type-aliases" />
|
||||
|
||||
/**
|
||||
* @see https://github.com/endiliey/react-ideal-image/blob/master/index.d.ts
|
||||
* Note: the original type definition is WRONG. getIcon & getMessage receive
|
||||
* full state object.
|
||||
*/
|
||||
declare module '@slorber/react-ideal-image' {
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentType,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type LoadingState = 'initial' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
export type State = {
|
||||
pickedSrc: {
|
||||
size: number;
|
||||
};
|
||||
loadInfo: 404 | null;
|
||||
loadState: LoadingState;
|
||||
};
|
||||
|
||||
export type IconKey =
|
||||
| 'load'
|
||||
| 'loading'
|
||||
| 'loaded'
|
||||
| 'error'
|
||||
| 'noicon'
|
||||
| 'offline';
|
||||
|
||||
export type SrcType = {
|
||||
width: number;
|
||||
src?: string;
|
||||
size?: number;
|
||||
format?: 'webp' | 'jpeg' | 'png' | 'gif';
|
||||
};
|
||||
|
||||
type ThemeKey = 'placeholder' | 'img' | 'icon' | 'noscript';
|
||||
|
||||
export interface ImageProps
|
||||
extends Omit<ComponentProps<'img'>, 'srcSet' | 'placeholder'> {
|
||||
/**
|
||||
* This function decides what icon to show based on the current state of the
|
||||
* component.
|
||||
*/
|
||||
getIcon?: (state: State) => IconKey;
|
||||
/**
|
||||
* This function decides what message to show based on the icon (returned
|
||||
* from `getIcon` prop) and the current state of the component.
|
||||
*/
|
||||
getMessage?: (icon: IconKey, state: State) => string | null;
|
||||
/**
|
||||
* This function is called as soon as the component enters the viewport and
|
||||
* is used to generate urls based on width and format if `props.srcSet`
|
||||
* doesn't provide `src` field.
|
||||
*/
|
||||
getUrl?: (srcType: SrcType) => string;
|
||||
/**
|
||||
* The Height of the image in px.
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* This provides a map of the icons. By default, the component uses icons
|
||||
* from material design, Implemented as React components with the SVG
|
||||
* element. You can customize icons
|
||||
*/
|
||||
icons?: Partial<{[icon in IconKey]: ComponentType}>;
|
||||
/**
|
||||
* This prop takes one of the 2 options, xhr and image.
|
||||
* Read more about it:
|
||||
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download
|
||||
*/
|
||||
loader?: 'xhr' | 'image';
|
||||
/**
|
||||
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip
|
||||
*/
|
||||
placeholder: {color: string} | {lqip: string};
|
||||
/**
|
||||
* This function decides if image should be downloaded automatically. The
|
||||
* default function returns false for a 2g network, for a 3g network it
|
||||
* decides based on `props.threshold` and for a 4g network it returns true
|
||||
* by default.
|
||||
*/
|
||||
shouldAutoDownload?: (options: {
|
||||
connection?: 'slow-2g' | '2g' | '3g' | '4g';
|
||||
size?: number;
|
||||
threshold?: number;
|
||||
possiblySlowNetwork?: boolean;
|
||||
}) => boolean;
|
||||
/**
|
||||
* This provides an array of sources of different format and size of the
|
||||
* image. Read more about it:
|
||||
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset
|
||||
*/
|
||||
srcSet: SrcType[];
|
||||
/**
|
||||
* This provides a theme to the component. By default, the component uses
|
||||
* inline styles, but it is also possible to use CSS modules and override
|
||||
* all styles.
|
||||
*/
|
||||
theme?: Partial<{[key in ThemeKey]: string | CSSProperties}>;
|
||||
/**
|
||||
* Tells how much to wait in milliseconds until consider the download to be
|
||||
* slow.
|
||||
*/
|
||||
threshold?: number;
|
||||
/**
|
||||
* Width of the image in px.
|
||||
*/
|
||||
width: number;
|
||||
}
|
||||
|
||||
export default function IdealImage(props: ImageProps): ReactNode;
|
||||
}
|
|
@ -5,6 +5,8 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/// <reference types="@docusaurus/module-type-aliases" />
|
||||
|
||||
declare module '@docusaurus/plugin-ideal-image' {
|
||||
export type PluginOptions = {
|
||||
/**
|
||||
|
@ -74,3 +76,122 @@ declare module '@theme/IdealImage' {
|
|||
}
|
||||
export default function IdealImage(props: Props): ReactNode;
|
||||
}
|
||||
|
||||
declare module '@theme/IdealImageLegacy' {
|
||||
/**
|
||||
* @see https://github.com/endiliey/react-ideal-image/blob/master/index.d.ts
|
||||
* Note: the original type definition is WRONG. getIcon & getMessage receive
|
||||
* full state object.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentType,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type LoadingState = 'initial' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
export type State = {
|
||||
pickedSrc: {
|
||||
size: number;
|
||||
};
|
||||
loadInfo: 404 | null;
|
||||
loadState: LoadingState;
|
||||
};
|
||||
|
||||
export type IconKey =
|
||||
| 'load'
|
||||
| 'loading'
|
||||
| 'loaded'
|
||||
| 'error'
|
||||
| 'noicon'
|
||||
| 'offline';
|
||||
|
||||
export type SrcType = {
|
||||
width: number;
|
||||
src?: string;
|
||||
size?: number;
|
||||
format?: 'webp' | 'jpeg' | 'png' | 'gif';
|
||||
};
|
||||
|
||||
type ThemeKey = 'placeholder' | 'img' | 'icon' | 'noscript';
|
||||
|
||||
export interface ImageProps
|
||||
extends Omit<ComponentProps<'img'>, 'srcSet' | 'placeholder'> {
|
||||
/**
|
||||
* This function decides what icon to show based on the current state of the
|
||||
* component.
|
||||
*/
|
||||
getIcon?: (state: State) => IconKey;
|
||||
/**
|
||||
* This function decides what message to show based on the icon (returned
|
||||
* from `getIcon` prop) and the current state of the component.
|
||||
*/
|
||||
getMessage?: (icon: IconKey, state: State) => string | null;
|
||||
/**
|
||||
* This function is called as soon as the component enters the viewport and
|
||||
* is used to generate urls based on width and format if `props.srcSet`
|
||||
* doesn't provide `src` field.
|
||||
*/
|
||||
getUrl?: (srcType: SrcType) => string;
|
||||
/**
|
||||
* The Height of the image in px.
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* This provides a map of the icons. By default, the component uses icons
|
||||
* from material design, Implemented as React components with the SVG
|
||||
* element. You can customize icons
|
||||
*/
|
||||
icons?: Partial<{[icon in IconKey]: ComponentType}>;
|
||||
/**
|
||||
* This prop takes one of the 2 options, xhr and image.
|
||||
* Read more about it:
|
||||
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download
|
||||
*/
|
||||
loader?: 'xhr' | 'image';
|
||||
/**
|
||||
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip
|
||||
*/
|
||||
placeholder: {color: string} | {lqip: string};
|
||||
/**
|
||||
* This function decides if image should be downloaded automatically. The
|
||||
* default function returns false for a 2g network, for a 3g network it
|
||||
* decides based on `props.threshold` and for a 4g network it returns true
|
||||
* by default.
|
||||
*/
|
||||
shouldAutoDownload?: (options: {
|
||||
connection?: 'slow-2g' | '2g' | '3g' | '4g';
|
||||
size?: number;
|
||||
threshold?: number;
|
||||
possiblySlowNetwork?: boolean;
|
||||
}) => boolean;
|
||||
/**
|
||||
* This provides an array of sources of different format and size of the
|
||||
* image. Read more about it:
|
||||
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset
|
||||
*/
|
||||
srcSet: SrcType[];
|
||||
/**
|
||||
* This provides a theme to the component. By default, the component uses
|
||||
* inline styles, but it is also possible to use CSS modules and override
|
||||
* all styles.
|
||||
*/
|
||||
theme?: Partial<{[key in ThemeKey]: string | CSSProperties}>;
|
||||
/**
|
||||
* Tells how much to wait in milliseconds until consider the download to be
|
||||
* slow.
|
||||
*/
|
||||
threshold?: number;
|
||||
/**
|
||||
* Width of the image in px.
|
||||
*/
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface Props extends ImageProps {}
|
||||
|
||||
export default function IdealImageLegacy(props: ImageProps): ReactNode;
|
||||
}
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
*/
|
||||
|
||||
import React, {type ReactNode} from 'react';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
import ReactIdealImage, {
|
||||
type IconKey,
|
||||
type State,
|
||||
} from '@slorber/react-ideal-image';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
} from '@theme/IdealImageLegacy';
|
||||
|
||||
import type {Props} from '@theme/IdealImage';
|
||||
|
||||
|
@ -93,7 +93,6 @@ export default function IdealImage(props: Props): ReactNode {
|
|||
|
||||
return (
|
||||
<ReactIdealImage
|
||||
{...propsRest}
|
||||
height={img.src.height ?? 100}
|
||||
width={img.src.width ?? 100}
|
||||
placeholder={{lqip: img.preSrc}}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
The MIT License (MIT)
|
||||
Copyright (c) 2017 stereobooster
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,13 @@
|
|||
# Legacy React IdealImage lib
|
||||
|
||||
This is legacy code from an npm package we forked, then internalized
|
||||
|
||||
See also:
|
||||
|
||||
- https://github.com/slorber/docusaurus-react-ideal-image
|
||||
- https://github.com/endiliey/react-ideal-image
|
||||
- https://github.com/stereobooster/react-ideal-image
|
||||
|
||||
---
|
||||
|
||||
TODO: we need to clean it up, remove what we don't need, and maintain it up to date
|
|
@ -0,0 +1,73 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Download icon Should render a snapshot that is good 1`] = `
|
||||
<svg
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"
|
||||
fill="#000"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
exports[`Loading icon Should render a snapshot that is good 1`] = `
|
||||
<svg
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M6,2V8H6V8L10,12L6,16V16H6V22H18V16H18V16L14,12L18,8V8H18V2H6M16,16.5V20H8V16.5L12,12.5L16,16.5M12,11.5L8,7.5V4H16V7.5L12,11.5Z"
|
||||
fill="#000"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
exports[`Offline icon Should render a snapshot that is good 1`] = `
|
||||
<svg
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M19.35 10.04C18.67 6.59 15.64 4 12 4c-1.48 0-2.85.43-4.01 1.17l1.46 1.46C10.21 6.23 11.08 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3 0 1.13-.64 2.11-1.56 2.62l1.45 1.45C23.16 18.16 24 16.68 24 15c0-2.64-2.05-4.78-4.65-4.96zM3 5.27l2.75 2.74C2.56 8.15 0 10.77 0 14c0 3.31 2.69 6 6 6h11.73l2 2L21 20.73 4.27 4 3 5.27zM7.73 10l8 8H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h1.73z"
|
||||
fill="#000"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
exports[`Warning icon Should render a snapshot that is good 1`] = `
|
||||
<svg
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
||||
fill="#000"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
|
@ -0,0 +1,32 @@
|
|||
import compose from '../components/composeStyle';
|
||||
|
||||
describe('composeStyle', () => {
|
||||
it('Should combine object classes into one className string', () => {
|
||||
const theme = {
|
||||
base: 'base',
|
||||
element: 'base__element',
|
||||
};
|
||||
const result = compose(theme.base, theme.element);
|
||||
expect(result.className).toEqual(`${theme.base} ${theme.element}`);
|
||||
});
|
||||
|
||||
it('Should return a styles object unmodified', () => {
|
||||
const style = {
|
||||
color: 'blue',
|
||||
margin: '1em 0',
|
||||
};
|
||||
const result = compose(style);
|
||||
const expected = style;
|
||||
expect(result.style).toEqual(expected);
|
||||
});
|
||||
|
||||
it('Should throw an error if given a parameter not an object or string', () => {
|
||||
const number = 1;
|
||||
try {
|
||||
compose(number);
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(`Unexpected value ${number}`);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,185 @@
|
|||
import {
|
||||
// guessMaxImageWidth,
|
||||
bytesToSize,
|
||||
selectSrc,
|
||||
fallbackParams,
|
||||
} from '../components/helpers';
|
||||
|
||||
/*
|
||||
describe('guessMaxImageWidth', () => {
|
||||
it('Should calculate the maximum image width', () => {
|
||||
const dimensions = {
|
||||
width: 400,
|
||||
height: 100,
|
||||
}
|
||||
const mockedWindow = {
|
||||
screen: {
|
||||
width: 100,
|
||||
},
|
||||
innerWidth: 1024,
|
||||
innerHeight: 768,
|
||||
}
|
||||
const expected = dimensions.width
|
||||
const result = guessMaxImageWidth(dimensions, mockedWindow)
|
||||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('Should calculate the maximum image width with screen changes', () => {
|
||||
const dimensions = {
|
||||
width: 400,
|
||||
height: 100,
|
||||
}
|
||||
const mockedWindow = {
|
||||
screen: {
|
||||
width: 100,
|
||||
},
|
||||
innerWidth: 50,
|
||||
innerHeight: 30,
|
||||
}
|
||||
const expected =
|
||||
(dimensions.width / mockedWindow.innerWidth) * mockedWindow.screen.width
|
||||
const result = guessMaxImageWidth(dimensions, mockedWindow)
|
||||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('Should calculate the maximum image width with screen changes and scroll', () => {
|
||||
const body = document.getElementsByTagName('body')[0]
|
||||
Object.defineProperty(body, 'clientHeight', {
|
||||
get: () => {
|
||||
return 400
|
||||
},
|
||||
})
|
||||
const dimensions = {
|
||||
width: 400,
|
||||
height: 100,
|
||||
}
|
||||
const mockedWindow = {
|
||||
screen: {
|
||||
width: 100,
|
||||
},
|
||||
innerWidth: 50,
|
||||
innerHeight: 100,
|
||||
}
|
||||
const expected = 450
|
||||
const result = guessMaxImageWidth(dimensions, mockedWindow)
|
||||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
describe('bytesToSize', () => {
|
||||
const bitsInKB = 1024;
|
||||
const bitsInMB = bitsInKB * bitsInKB;
|
||||
|
||||
it('Should correctly calculate size less than a single byte', () => {
|
||||
const bytes = 4;
|
||||
const result = bytesToSize(bytes);
|
||||
expect(result).toEqual(`${bytes} Bytes`);
|
||||
});
|
||||
|
||||
it('Should correctly calculate size one bit less than a kilobyte', () => {
|
||||
const bytes = bitsInKB - 1;
|
||||
const result = bytesToSize(bytes);
|
||||
expect(result).toEqual(`${bytes} Bytes`);
|
||||
});
|
||||
|
||||
it('Should correctly calculate size of exactly a kilobyte', () => {
|
||||
const expected = '1.0 KB';
|
||||
const result = bytesToSize(bitsInKB);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('Should correctly calculate decimal value of exactly a kilobyte plus 100 bits', () => {
|
||||
const expected = '1.1 KB';
|
||||
const result = bytesToSize(bitsInKB + 100);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('Should correctly calculate size of exactly a megabybte', () => {
|
||||
const expected = '1.0 MB';
|
||||
const result = bytesToSize(bitsInMB);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectSrc', () => {
|
||||
it('Should throw if provided no srcSet', () => {
|
||||
const props = {
|
||||
srcSet: [],
|
||||
};
|
||||
try {
|
||||
selectSrc(props);
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Need at least one item in srcSet');
|
||||
}
|
||||
});
|
||||
|
||||
it('Should throw if provided no supported formats in srcSet', () => {
|
||||
const props = {
|
||||
srcSet: [{format: 'webp'}],
|
||||
};
|
||||
try {
|
||||
selectSrc(props);
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'Need at least one supported format item in srcSet',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('Should select the right source with an image greater than the max width', () => {
|
||||
const srcThatShouldBeSelected = {format: 'jpeg', width: 100};
|
||||
const props = {
|
||||
srcSet: [srcThatShouldBeSelected],
|
||||
maxImageWidth: 100,
|
||||
};
|
||||
const expected = srcThatShouldBeSelected;
|
||||
const result = selectSrc(props);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('Should select the right source with an image less than the max width', () => {
|
||||
const srcThatShouldBeSelected = {format: 'jpeg', width: 99};
|
||||
const srcThatShouldNotBeSelected = {format: 'jpeg', width: 98};
|
||||
const props = {
|
||||
srcSet: [srcThatShouldBeSelected, srcThatShouldNotBeSelected],
|
||||
maxImageWidth: 100,
|
||||
};
|
||||
const expected = srcThatShouldBeSelected;
|
||||
const result = selectSrc(props);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('Should use webp images if supported', () => {
|
||||
const srcThatShouldBeSelected = {format: 'webp', width: 99};
|
||||
const srcThatShouldNotBeSelected = {format: 'webp', width: 98};
|
||||
const props = {
|
||||
srcSet: [srcThatShouldBeSelected, srcThatShouldNotBeSelected],
|
||||
supportsWebp: true,
|
||||
maxImageWidth: 100,
|
||||
};
|
||||
const expected = srcThatShouldBeSelected;
|
||||
const result = selectSrc(props);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
describe('fallbackParams', () => {
|
||||
it('Should return an empty object when run in the browser environment', () => {
|
||||
const result = fallbackParams({
|
||||
srcSet: [
|
||||
{
|
||||
format: 'webp',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
*/
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import {guessMaxImageWidth, fallbackParams} from '../components/helpers';
|
||||
|
||||
describe('guessMaxImageWidth', () => {
|
||||
const expected = 0;
|
||||
it(`Should return ${expected} when run in the node environment`, () => {
|
||||
const result = guessMaxImageWidth({width: 100});
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FallbackParams', () => {
|
||||
const props = {
|
||||
srcSet: [
|
||||
{
|
||||
format: 'webp',
|
||||
},
|
||||
{
|
||||
format: 'jpeg',
|
||||
},
|
||||
{
|
||||
format: 'png',
|
||||
},
|
||||
],
|
||||
getUrl: jest.fn(),
|
||||
};
|
||||
|
||||
it('Should return an object when run in the node environment', () => {
|
||||
const result = fallbackParams(props);
|
||||
expect(result).not.toEqual({});
|
||||
expect(props.getUrl).toHaveBeenCalledWith({
|
||||
format: 'jpeg',
|
||||
});
|
||||
expect(props.getUrl).toHaveBeenCalledWith({
|
||||
format: 'png',
|
||||
});
|
||||
expect(result.ssr).toEqual(true);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Download from '../components/Icon/Download';
|
||||
import Loading from '../components/Icon/Loading';
|
||||
import Offline from '../components/Icon/Offline';
|
||||
import Warning from '../components/Icon/Warning';
|
||||
|
||||
const snapshotTestDescription = 'Should render a snapshot that is good';
|
||||
|
||||
describe('Download icon', () => {
|
||||
it(snapshotTestDescription, () => {
|
||||
const download = renderer.create(<Download />).toJSON();
|
||||
expect(download).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading icon', () => {
|
||||
it(snapshotTestDescription, () => {
|
||||
const loading = renderer.create(<Loading />).toJSON();
|
||||
expect(loading).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Offline icon', () => {
|
||||
it(snapshotTestDescription, () => {
|
||||
const offline = renderer.create(<Offline />).toJSON();
|
||||
expect(offline).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Warning icon', () => {
|
||||
it(snapshotTestDescription, () => {
|
||||
const warning = renderer.create(<Warning />).toJSON();
|
||||
expect(warning).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
// This icon is from "material design icons"
|
||||
// It is licensed under Apache License 2.0
|
||||
// Full text is available here
|
||||
// https://github.com/google/material-design-icons/blob/master/LICENSE
|
||||
import React from 'react';
|
||||
import Icon from './index';
|
||||
|
||||
const Download = (props) => (
|
||||
<Icon
|
||||
{...props}
|
||||
path="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"
|
||||
/>
|
||||
);
|
||||
|
||||
export default Download;
|
|
@ -0,0 +1,15 @@
|
|||
// This icon is from "material design icons"
|
||||
// It is licensed under Apache License 2.0
|
||||
// Full text is available here
|
||||
// https://github.com/google/material-design-icons/blob/master/LICENSE
|
||||
import React from 'react';
|
||||
import Icon from './index';
|
||||
|
||||
const Loading = (props) => (
|
||||
<Icon
|
||||
{...props}
|
||||
path="M6,2V8H6V8L10,12L6,16V16H6V22H18V16H18V16L14,12L18,8V8H18V2H6M16,16.5V20H8V16.5L12,12.5L16,16.5M12,11.5L8,7.5V4H16V7.5L12,11.5Z"
|
||||
/>
|
||||
);
|
||||
|
||||
export default Loading;
|
|
@ -0,0 +1,15 @@
|
|||
// This icon is from "material design icons"
|
||||
// It is licensed under Apache License 2.0
|
||||
// Full text is available here
|
||||
// https://github.com/google/material-design-icons/blob/master/LICENSE
|
||||
import React from 'react';
|
||||
import Icon from './index';
|
||||
|
||||
const Offline = (props) => (
|
||||
<Icon
|
||||
{...props}
|
||||
path="M19.35 10.04C18.67 6.59 15.64 4 12 4c-1.48 0-2.85.43-4.01 1.17l1.46 1.46C10.21 6.23 11.08 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3 0 1.13-.64 2.11-1.56 2.62l1.45 1.45C23.16 18.16 24 16.68 24 15c0-2.64-2.05-4.78-4.65-4.96zM3 5.27l2.75 2.74C2.56 8.15 0 10.77 0 14c0 3.31 2.69 6 6 6h11.73l2 2L21 20.73 4.27 4 3 5.27zM7.73 10l8 8H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h1.73z"
|
||||
/>
|
||||
);
|
||||
|
||||
export default Offline;
|
|
@ -0,0 +1,15 @@
|
|||
// This icon is from "material design icons"
|
||||
// It is licensed under Apache License 2.0
|
||||
// Full text is available here
|
||||
// https://github.com/google/material-design-icons/blob/master/LICENSE
|
||||
import React from 'react';
|
||||
import Icon from './index';
|
||||
|
||||
const Warning = (props) => (
|
||||
<Icon
|
||||
{...props}
|
||||
path="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
||||
/>
|
||||
);
|
||||
|
||||
export default Warning;
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
// import PropTypes from 'prop-types'
|
||||
|
||||
const Icon = ({size = 24, fill = '#000', className, path}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
className={className}>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path fill={fill} d={path} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/*
|
||||
Icon.propTypes = {
|
||||
size: PropTypes.number,
|
||||
fill: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
path: PropTypes.string.isRequired,
|
||||
}
|
||||
*/
|
||||
|
||||
export default Icon;
|
|
@ -0,0 +1,353 @@
|
|||
import React, {Component} from 'react';
|
||||
// import PropTypes from 'prop-types'
|
||||
import {Waypoint} from 'react-waypoint';
|
||||
import Media from '../Media';
|
||||
import {icons, loadStates} from '../constants';
|
||||
import {xhrLoader, imageLoader, timeout, combineCancel} from '../loaders';
|
||||
import {
|
||||
guessMaxImageWidth,
|
||||
bytesToSize,
|
||||
supportsWebp,
|
||||
ssr,
|
||||
nativeConnection,
|
||||
selectSrc,
|
||||
fallbackParams,
|
||||
} from '../helpers';
|
||||
|
||||
const {initial, loading, loaded, error} = loadStates;
|
||||
|
||||
const defaultShouldAutoDownload = ({
|
||||
connection,
|
||||
size,
|
||||
threshold,
|
||||
possiblySlowNetwork,
|
||||
}) => {
|
||||
if (possiblySlowNetwork) return false;
|
||||
if (!connection) return true;
|
||||
const {downlink, rtt, effectiveType} = connection;
|
||||
switch (effectiveType) {
|
||||
case 'slow-2g':
|
||||
case '2g':
|
||||
return false;
|
||||
case '3g':
|
||||
if (downlink && size && threshold) {
|
||||
return (size * 8) / (downlink * 1000) + rtt < threshold;
|
||||
}
|
||||
return false;
|
||||
case '4g':
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultGetMessage = (icon, state) => {
|
||||
switch (icon) {
|
||||
case icons.noicon:
|
||||
case icons.loaded:
|
||||
return null;
|
||||
case icons.loading:
|
||||
return 'Loading...';
|
||||
case icons.load:
|
||||
// we can show `alt` here
|
||||
const {pickedSrc} = state;
|
||||
const {size} = pickedSrc;
|
||||
if (size) {
|
||||
return [
|
||||
'Click to load (',
|
||||
<nobr key="nb">{bytesToSize(size)}</nobr>,
|
||||
')',
|
||||
];
|
||||
} else {
|
||||
return 'Click to load';
|
||||
}
|
||||
case icons.offline:
|
||||
return 'Your browser is offline. Image not loaded';
|
||||
case icons.error:
|
||||
const {loadInfo} = state;
|
||||
if (loadInfo === 404) {
|
||||
return '404. Image not found';
|
||||
} else {
|
||||
return 'Error. Click to reload';
|
||||
}
|
||||
default:
|
||||
throw new Error(`Wrong icon: ${icon}`);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultGetIcon = (state) => {
|
||||
const {loadState, onLine, overThreshold, userTriggered} = state;
|
||||
if (ssr) return icons.noicon;
|
||||
switch (loadState) {
|
||||
case loaded:
|
||||
return icons.loaded;
|
||||
case loading:
|
||||
return overThreshold ? icons.loading : icons.noicon;
|
||||
case initial:
|
||||
if (onLine) {
|
||||
const {shouldAutoDownload} = state;
|
||||
if (shouldAutoDownload === undefined) return icons.noicon;
|
||||
return userTriggered || !shouldAutoDownload ? icons.load : icons.noicon;
|
||||
} else {
|
||||
return icons.offline;
|
||||
}
|
||||
case error:
|
||||
return onLine ? icons.error : icons.offline;
|
||||
default:
|
||||
throw new Error(`Wrong state: ${loadState}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default class IdealImage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// TODO: validate props.srcSet
|
||||
this.state = {
|
||||
loadState: initial,
|
||||
connection: nativeConnection
|
||||
? {
|
||||
downlink: navigator.connection.downlink, // megabits per second
|
||||
rtt: navigator.connection.rtt, // ms
|
||||
effectiveType: navigator.connection.effectiveType, // 'slow-2g', '2g', '3g', or '4g'
|
||||
}
|
||||
: null,
|
||||
onLine: true,
|
||||
overThreshold: false,
|
||||
inViewport: false,
|
||||
userTriggered: false,
|
||||
possiblySlowNetwork: false,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
static propTypes = {
|
||||
/!** how much to wait in ms until concider download to slow *!/
|
||||
threshold: PropTypes.number,
|
||||
/!** function to generate src based on width and format *!/
|
||||
getUrl: PropTypes.func,
|
||||
/!** array of sources *!/
|
||||
srcSet: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
width: PropTypes.number.isRequired,
|
||||
src: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
format: PropTypes.oneOf(['jpeg', 'jpg', 'webp', 'png', 'gif']),
|
||||
}),
|
||||
).isRequired,
|
||||
/!** function which decides if image should be downloaded *!/
|
||||
shouldAutoDownload: PropTypes.func,
|
||||
/!** function which decides what message to show *!/
|
||||
getMessage: PropTypes.func,
|
||||
/!** function which decides what icon to show *!/
|
||||
getIcon: PropTypes.func,
|
||||
/!** type of loader *!/
|
||||
loader: PropTypes.oneOf(['image', 'xhr']),
|
||||
/!** Width of the image in px *!/
|
||||
width: PropTypes.number.isRequired,
|
||||
/!** Height of the image in px *!/
|
||||
height: PropTypes.number.isRequired,
|
||||
placeholder: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
/!** Solid color placeholder *!/
|
||||
color: PropTypes.string.isRequired,
|
||||
}),
|
||||
PropTypes.shape({
|
||||
/!**
|
||||
* [Low Quality Image Placeholder](https://github.com/zouhir/lqip)
|
||||
* [SVG-Based Image Placeholder](https://github.com/technopagan/sqip)
|
||||
* base64 encoded image of low quality
|
||||
*!/
|
||||
lqip: PropTypes.string.isRequired,
|
||||
}),
|
||||
]).isRequired,
|
||||
/!** Map of icons *!/
|
||||
icons: PropTypes.object.isRequired,
|
||||
/!** theme object - CSS Modules or React styles *!/
|
||||
theme: PropTypes.object.isRequired,
|
||||
}*/
|
||||
|
||||
static defaultProps = {
|
||||
shouldAutoDownload: defaultShouldAutoDownload,
|
||||
getMessage: defaultGetMessage,
|
||||
getIcon: defaultGetIcon,
|
||||
loader: 'xhr',
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (nativeConnection) {
|
||||
this.updateConnection = () => {
|
||||
if (!navigator.onLine) return;
|
||||
if (this.state.loadState === initial) {
|
||||
this.setState({
|
||||
connection: {
|
||||
effectiveType: navigator.connection.effectiveType,
|
||||
downlink: navigator.connection.downlink,
|
||||
rtt: navigator.connection.rtt,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
navigator.connection.addEventListener('onchange', this.updateConnection);
|
||||
} else if (this.props.threshold) {
|
||||
this.possiblySlowNetworkListener = (e) => {
|
||||
if (this.state.loadState !== initial) return;
|
||||
const {possiblySlowNetwork} = e.detail;
|
||||
if (!this.state.possiblySlowNetwork && possiblySlowNetwork) {
|
||||
this.setState({possiblySlowNetwork});
|
||||
}
|
||||
};
|
||||
window.document.addEventListener(
|
||||
'possiblySlowNetwork',
|
||||
this.possiblySlowNetworkListener,
|
||||
);
|
||||
}
|
||||
this.updateOnlineStatus = () => this.setState({onLine: navigator.onLine});
|
||||
this.updateOnlineStatus();
|
||||
window.addEventListener('online', this.updateOnlineStatus);
|
||||
window.addEventListener('offline', this.updateOnlineStatus);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clear();
|
||||
if (nativeConnection) {
|
||||
navigator.connection.removeEventListener(
|
||||
'onchange',
|
||||
this.updateConnection,
|
||||
);
|
||||
} else if (this.props.threshold) {
|
||||
window.document.removeEventListener(
|
||||
'possiblySlowNetwork',
|
||||
this.possiblySlowNetworkListener,
|
||||
);
|
||||
}
|
||||
window.removeEventListener('online', this.updateOnlineStatus);
|
||||
window.removeEventListener('offline', this.updateOnlineStatus);
|
||||
}
|
||||
|
||||
onClick = () => {
|
||||
const {loadState, onLine, overThreshold} = this.state;
|
||||
if (!onLine) return;
|
||||
switch (loadState) {
|
||||
case loading:
|
||||
if (overThreshold) this.cancel(true);
|
||||
return;
|
||||
case loaded:
|
||||
// nothing
|
||||
return;
|
||||
case initial:
|
||||
case error:
|
||||
this.load(true);
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Wrong state: ${loadState}`);
|
||||
}
|
||||
};
|
||||
|
||||
clear() {
|
||||
if (this.loader) {
|
||||
this.loader.cancel();
|
||||
this.loader = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
cancel(userTriggered) {
|
||||
if (loading !== this.state.loadState) return;
|
||||
this.clear();
|
||||
this.loadStateChange(initial, userTriggered);
|
||||
}
|
||||
|
||||
loadStateChange(loadState, userTriggered, loadInfo = null) {
|
||||
this.setState({
|
||||
loadState,
|
||||
overThreshold: false,
|
||||
userTriggered: !!userTriggered,
|
||||
loadInfo,
|
||||
});
|
||||
}
|
||||
|
||||
load = (userTriggered) => {
|
||||
const {loadState, url} = this.state;
|
||||
if (ssr || loaded === loadState || loading === loadState) return;
|
||||
this.loadStateChange(loading, userTriggered);
|
||||
|
||||
const {threshold} = this.props;
|
||||
const loader =
|
||||
this.props.loader === 'xhr' ? xhrLoader(url) : imageLoader(url);
|
||||
loader
|
||||
.then(() => {
|
||||
this.clear();
|
||||
this.loadStateChange(loaded, false);
|
||||
})
|
||||
.catch((e) => {
|
||||
this.clear();
|
||||
if (e.status === 404) {
|
||||
this.loadStateChange(error, false, 404);
|
||||
} else {
|
||||
this.loadStateChange(error, false);
|
||||
}
|
||||
});
|
||||
|
||||
if (threshold) {
|
||||
const timeoutLoader = timeout(threshold);
|
||||
timeoutLoader.then(() => {
|
||||
if (!this.loader) return;
|
||||
window.document.dispatchEvent(
|
||||
new CustomEvent('possiblySlowNetwork', {
|
||||
detail: {possiblySlowNetwork: true},
|
||||
}),
|
||||
);
|
||||
this.setState({overThreshold: true});
|
||||
if (!this.state.userTriggered) this.cancel(true);
|
||||
});
|
||||
this.loader = combineCancel(loader, timeoutLoader);
|
||||
} else {
|
||||
this.loader = loader;
|
||||
}
|
||||
};
|
||||
|
||||
onEnter = () => {
|
||||
if (this.state.inViewport) return;
|
||||
this.setState({inViewport: true});
|
||||
const pickedSrc = selectSrc({
|
||||
srcSet: this.props.srcSet,
|
||||
maxImageWidth:
|
||||
this.props.srcSet.length > 1
|
||||
? guessMaxImageWidth(this.state.dimensions) // eslint-disable-line react/no-access-state-in-setstate
|
||||
: 0,
|
||||
supportsWebp,
|
||||
});
|
||||
const {getUrl} = this.props;
|
||||
const url = getUrl ? getUrl(pickedSrc) : pickedSrc.src;
|
||||
const shouldAutoDownload = this.props.shouldAutoDownload({
|
||||
...this.state, // eslint-disable-line react/no-access-state-in-setstate
|
||||
size: pickedSrc.size,
|
||||
});
|
||||
this.setState({pickedSrc, shouldAutoDownload, url}, () => {
|
||||
if (shouldAutoDownload) this.load(false);
|
||||
});
|
||||
};
|
||||
|
||||
onLeave = () => {
|
||||
if (this.state.loadState === loading && !this.state.userTriggered) {
|
||||
this.setState({inViewport: false});
|
||||
this.cancel(false);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const icon = this.props.getIcon(this.state);
|
||||
const message = this.props.getMessage(icon, this.state);
|
||||
return (
|
||||
<Waypoint onEnter={this.onEnter} onLeave={this.onLeave}>
|
||||
<Media
|
||||
{...this.props}
|
||||
{...fallbackParams(this.props)}
|
||||
onClick={this.onClick}
|
||||
icon={icon}
|
||||
src={this.state.url || ''}
|
||||
onDimensions={(dimensions) => this.setState({dimensions})}
|
||||
message={message}
|
||||
/>
|
||||
</Waypoint>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import IdealImage from '../IdealImage';
|
||||
import icons from '../icons';
|
||||
import theme from '../theme';
|
||||
|
||||
const IdealImageWithDefaults = ({
|
||||
icons: iconsProp = icons,
|
||||
theme: themeProp = theme,
|
||||
...props
|
||||
}) => <IdealImage {...props} icons={iconsProp} theme={themeProp} />;
|
||||
|
||||
export default IdealImageWithDefaults;
|
|
@ -0,0 +1,169 @@
|
|||
import React, {PureComponent} from 'react';
|
||||
// import PropTypes from 'prop-types'
|
||||
import compose from '../composeStyle';
|
||||
import {icons as defaultIcons} from '../constants';
|
||||
|
||||
const {load, loading, loaded, error, noicon, offline} = defaultIcons;
|
||||
|
||||
export default class Media extends PureComponent {
|
||||
/*static propTypes = {
|
||||
/!** URL of the image *!/
|
||||
src: PropTypes.string.isRequired,
|
||||
/!** Width of the image in px *!/
|
||||
width: PropTypes.number.isRequired,
|
||||
/!** Height of the image in px *!/
|
||||
height: PropTypes.number.isRequired,
|
||||
placeholder: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
/!** Solid color placeholder *!/
|
||||
color: PropTypes.string.isRequired,
|
||||
}),
|
||||
PropTypes.shape({
|
||||
/!**
|
||||
* [Low Quality Image Placeholder](https://github.com/zouhir/lqip)
|
||||
* [SVG-Based Image Placeholder](https://github.com/technopagan/sqip)
|
||||
* base64 encoded image of low quality
|
||||
*!/
|
||||
lqip: PropTypes.string.isRequired,
|
||||
}),
|
||||
]).isRequired,
|
||||
/!** display icon *!/
|
||||
icon: PropTypes.oneOf([load, loading, loaded, error, noicon, offline])
|
||||
.isRequired,
|
||||
/!** Map of icons *!/
|
||||
icons: PropTypes.object.isRequired,
|
||||
/!** theme object - CSS Modules or React styles *!/
|
||||
theme: PropTypes.object.isRequired,
|
||||
/!** Alternative text *!/
|
||||
alt: PropTypes.string,
|
||||
/!** Color of the icon *!/
|
||||
iconColor: PropTypes.string,
|
||||
/!** Size of the icon in px *!/
|
||||
iconSize: PropTypes.number,
|
||||
/!** React's style attribute for root element of the component *!/
|
||||
style: PropTypes.object,
|
||||
/!** React's className attribute for root element of the component *!/
|
||||
className: PropTypes.string,
|
||||
/!** On click handler *!/
|
||||
onClick: PropTypes.func,
|
||||
/!** callback to get dimensions of the placeholder *!/
|
||||
onDimensions: PropTypes.func,
|
||||
/!** message to show below the icon *!/
|
||||
message: PropTypes.node,
|
||||
/!** reference for Waypoint *!/
|
||||
innerRef: PropTypes.func,
|
||||
/!** noscript image src *!/
|
||||
nsSrc: PropTypes.string,
|
||||
/!** noscript image srcSet *!/
|
||||
nsSrcSet: PropTypes.string,
|
||||
}*/
|
||||
|
||||
static defaultProps = {
|
||||
iconColor: '#fff',
|
||||
iconSize: 64,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {isMounted: false};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({isMounted: true});
|
||||
|
||||
if (this.props.onDimensions && this.dimensionElement)
|
||||
/* Firefox returns 0 for both clientWidth and clientHeight.
|
||||
To fix this we can check the parentNode's clientWidth and clientHeight as a fallback. */
|
||||
this.props.onDimensions({
|
||||
width:
|
||||
this.dimensionElement.clientWidth ||
|
||||
this.dimensionElement.parentNode.clientWidth,
|
||||
height:
|
||||
this.dimensionElement.clientHeight ||
|
||||
this.dimensionElement.parentNode.clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
renderIcon(props) {
|
||||
const {icon, icons, iconColor: fill, iconSize: size, theme} = props;
|
||||
const iconToRender = icons[icon];
|
||||
if (!iconToRender) return null;
|
||||
const styleOrClass = compose(
|
||||
{width: size + 100, height: size, color: fill},
|
||||
theme.icon,
|
||||
);
|
||||
return React.createElement('div', styleOrClass, [
|
||||
React.createElement(iconToRender, {fill, size, key: 'icon'}),
|
||||
React.createElement('br', {key: 'br'}),
|
||||
this.props.message,
|
||||
]);
|
||||
}
|
||||
|
||||
renderImage(props) {
|
||||
return props.icon === loaded ? (
|
||||
<img
|
||||
{...compose(props.theme.img)}
|
||||
src={props.src}
|
||||
alt={props.alt}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
{...compose(props.theme.img)}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
ref={(ref) => (this.dimensionElement = ref)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderNoscript(props) {
|
||||
// render noscript in ssr + hydration to avoid hydration mismatch error
|
||||
return this.state.isMounted ? null : (
|
||||
<noscript>
|
||||
<img
|
||||
{...compose(props.theme.img, props.theme.noscript)}
|
||||
src={props.nsSrc}
|
||||
srcSet={props.nsSrcSet}
|
||||
alt={props.alt}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
/>
|
||||
</noscript>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
const {placeholder, theme} = props;
|
||||
let background;
|
||||
if (props.icon === loaded) {
|
||||
background = {};
|
||||
} else if (placeholder.lqip) {
|
||||
background = {
|
||||
backgroundImage: `url("${placeholder.lqip}")`,
|
||||
};
|
||||
} else {
|
||||
background = {
|
||||
backgroundColor: placeholder.color,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div
|
||||
{...compose(
|
||||
theme.placeholder,
|
||||
background,
|
||||
props.style,
|
||||
props.className,
|
||||
)}
|
||||
onClick={this.props.onClick}
|
||||
onKeyPress={this.props.onClick}
|
||||
ref={this.props.innerRef}>
|
||||
{this.renderImage(props)}
|
||||
{this.renderNoscript(props)}
|
||||
{this.renderIcon(props)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
All possible states of the component
|
||||
|
||||
```js
|
||||
const lqip =
|
||||
'data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAA4DASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUG/8QAIRAAAQQDAAEFAAAAAAAAAAAAAQIDBREABAYhEjEyQVH/xAAUAQEAAAAAAAAAAAAAAAAAAAAE/8QAGBEBAAMBAAAAAAAAAAAAAAAAAQACIRH/2gAMAwEAAhEDEQA/AMJ2DG+7Dw0nz8gsx+uyhlxnWdLakOlfzpIF3aRf1WT5t96P5+N1ug9Tu7ZWS8q1gG6B8H2FDz+YxhjUrEOdZ//Z';
|
||||
|
||||
const sqip =
|
||||
"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4774 3024'%3e%3cfilter id='b'%3e%3cfeGaussianBlur stdDeviation='12' /%3e%3c/filter%3e%3cpath fill='%23515a57' d='M0 0h4774v3021H0z'/%3e%3cg filter='url(%23b)' transform='translate(9.3 9.3) scale(18.64844)' fill-opacity='.5'%3e%3cellipse fill='whitefef' rx='1' ry='1' transform='matrix(74.55002 60.89891 -21.7939 26.67923 151.8 104.4)'/%3e%3cellipse fill='black80c' cx='216' cy='49' rx='59' ry='59'/%3e%3cellipse fill='black60e' cx='22' cy='60' rx='46' ry='89'/%3e%3cellipse fill='%23ffebd5' cx='110' cy='66' rx='42' ry='28'/%3e%3cellipse fill='whiteff9' rx='1' ry='1' transform='rotate(33.3 -113.2 392.6) scale(42.337 17.49703)'/%3e%3cellipse fill='%23031f1e' rx='1' ry='1' transform='matrix(163.4651 -64.93326 6.77862 17.06471 111 16.4)'/%3e%3cpath fill='whitefea' d='M66 74l9 39 16-44z'/%3e%3cellipse fill='%23a28364' rx='1' ry='1' transform='rotate(-32.4 253.2 -179) scale(30.79511 43.65381)'/%3e%3cpath fill='%231a232c' d='M40 139l61-57 33 95z'/%3e%3cpath fill='%230a222b' d='M249.8 153.3l-48.1-48 32.5-32.6 48.1 48z'/%3e%3c/g%3e%3c/svg%3e";
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th align="left" width="100">
|
||||
load
|
||||
</th>
|
||||
<td>
|
||||
<MediaWithDefaults
|
||||
width={3500}
|
||||
height={2095}
|
||||
placeholder={{lqip: lqip}}
|
||||
src="andre-spieker-238-unsplash.jpg"
|
||||
style={{maxWidth: 200}}
|
||||
icon={'load'}
|
||||
/>
|
||||
</td>
|
||||
<th align="left" width="100">
|
||||
noicon
|
||||
</th>
|
||||
<td>
|
||||
<MediaWithDefaults
|
||||
width={3500}
|
||||
height={2095}
|
||||
placeholder={{lqip: lqip}}
|
||||
src="andre-spieker-238-unsplash.jpg"
|
||||
style={{maxWidth: 200}}
|
||||
icon={'noicon'}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th align="left">loading</th>
|
||||
<td>
|
||||
<MediaWithDefaults
|
||||
width={3500}
|
||||
height={2095}
|
||||
placeholder={{lqip: lqip}}
|
||||
src="andre-spieker-238-unsplash.jpg"
|
||||
style={{maxWidth: 200}}
|
||||
icon={'loading'}
|
||||
/>
|
||||
</td>
|
||||
<th align="left">offline</th>
|
||||
<td>
|
||||
<MediaWithDefaults
|
||||
width={3500}
|
||||
height={2095}
|
||||
placeholder={{lqip: lqip}}
|
||||
src="andre-spieker-238-unsplash.jpg"
|
||||
style={{maxWidth: 200}}
|
||||
icon={'offline'}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th align="left">loaded</th>
|
||||
<td>
|
||||
<MediaWithDefaults
|
||||
width={3500}
|
||||
height={2095}
|
||||
placeholder={{lqip: lqip}}
|
||||
src="andre-spieker-238-unsplash.jpg"
|
||||
style={{maxWidth: 200}}
|
||||
icon={'loaded'}
|
||||
/>
|
||||
</td>
|
||||
<th align="left">error</th>
|
||||
<td>
|
||||
<MediaWithDefaults
|
||||
width={3500}
|
||||
height={2095}
|
||||
placeholder={{lqip: lqip}}
|
||||
src="andre-spieker-238-unsplash.jpg"
|
||||
style={{maxWidth: 200}}
|
||||
icon={'error'}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>;
|
||||
```
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import Media from '../Media';
|
||||
import icons from '../icons';
|
||||
import theme from '../theme';
|
||||
|
||||
const MediaWithDefaults = ({
|
||||
icons: iconsProp = icons,
|
||||
theme: themeProp = theme,
|
||||
...props
|
||||
}) => <Media {...props} icons={iconsProp} theme={themeProp} />;
|
||||
|
||||
export default MediaWithDefaults;
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Composes styles and/or classes
|
||||
*
|
||||
* For classes it will concat them in in one string
|
||||
* and return as `className` property.
|
||||
* Alternative is https://github.com/JedWatson/classnames
|
||||
*
|
||||
* For objects it will merge them in one object
|
||||
* and return as `style` property.
|
||||
*
|
||||
* Usage:
|
||||
* Asume you have `theme` object, which can be css-module
|
||||
* or object or other css-in-js compatible with css-module
|
||||
*
|
||||
* <a {...compose(theme.link, theme.active, {color: "#000"})}>link</a>
|
||||
*
|
||||
* @returns {{className: string, style: object}} - params for React component
|
||||
*/
|
||||
export default (...stylesOrClasses) => {
|
||||
const classes = [];
|
||||
let style;
|
||||
for (const obj of stylesOrClasses) {
|
||||
if (obj instanceof Object) {
|
||||
Object.assign(style || (style = {}), obj);
|
||||
} else if (obj === undefined || obj === false) {
|
||||
// ignore
|
||||
} else if (typeof obj === 'string') {
|
||||
classes.push(obj);
|
||||
} else {
|
||||
throw new Error(`Unexpected value ${obj}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
className: classes.length > 1 ? classes.join(' ') : classes[0],
|
||||
style,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
const load = 'load';
|
||||
const loading = 'loading';
|
||||
const loaded = 'loaded';
|
||||
const error = 'error';
|
||||
const noicon = 'noicon';
|
||||
const offline = 'offline';
|
||||
|
||||
export const icons = {
|
||||
load,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
noicon,
|
||||
offline,
|
||||
};
|
||||
|
||||
const initial = 'initial';
|
||||
|
||||
export const loadStates = {
|
||||
initial,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
};
|
|
@ -0,0 +1,142 @@
|
|||
export const ssr =
|
||||
typeof window === 'undefined' || window.navigator.userAgent === 'ReactSnap';
|
||||
|
||||
export const nativeConnection = !ssr && !!window.navigator.connection;
|
||||
|
||||
// export const getScreenWidth = () => {
|
||||
// if (ssr) return 0
|
||||
// const devicePixelRatio = window.devicePixelRatio || 1
|
||||
// const {screen} = window
|
||||
// const {width} = screen
|
||||
// // const angle = (screen.orientation && screen.orientation.angle) || 0
|
||||
// // return Math.max(width, height)
|
||||
// // const rotated = Math.floor(angle / 90) % 2 !== 0
|
||||
// // return (rotated ? height : width) * devicePixelRatio
|
||||
// return width * devicePixelRatio
|
||||
// }
|
||||
// export const screenWidth = getScreenWidth()
|
||||
|
||||
export const guessMaxImageWidth = (dimensions, w) => {
|
||||
if (ssr) return 0;
|
||||
|
||||
// Default to window object but don't use window as a default
|
||||
// parameter so that this can be used on the server as well
|
||||
if (!w) {
|
||||
w = window;
|
||||
}
|
||||
|
||||
const imgWidth = dimensions.width;
|
||||
|
||||
const {screen} = w;
|
||||
const sWidth = screen.width;
|
||||
const sHeight = screen.height;
|
||||
|
||||
const {documentElement} = document;
|
||||
const windowWidth = w.innerWidth || documentElement.clientWidth;
|
||||
const windowHeight = w.innerHeight || documentElement.clientHeight;
|
||||
const devicePixelRatio = w.devicePixelRatio || 1;
|
||||
|
||||
const windowResized = sWidth > windowWidth;
|
||||
|
||||
let result;
|
||||
if (windowResized) {
|
||||
const body = document.getElementsByTagName('body')[0];
|
||||
const scrollWidth = windowWidth - imgWidth;
|
||||
const isScroll =
|
||||
body.clientHeight > windowHeight || body.clientHeight > sHeight;
|
||||
if (isScroll && scrollWidth <= 15) {
|
||||
result = sWidth - scrollWidth;
|
||||
} else {
|
||||
result = (imgWidth / windowWidth) * sWidth;
|
||||
}
|
||||
} else {
|
||||
result = imgWidth;
|
||||
}
|
||||
|
||||
return result * devicePixelRatio;
|
||||
};
|
||||
|
||||
export const bytesToSize = (bytes) => {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return 'n/a';
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10);
|
||||
if (i === 0) return `${bytes} ${sizes[i]}`;
|
||||
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// async function supportsWebp() {
|
||||
// if (typeof createImageBitmap === 'undefined' || typeof fetch === 'undefined')
|
||||
// return false
|
||||
// return fetch(
|
||||
// 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=',
|
||||
// )
|
||||
// .then(response => response.blob())
|
||||
// .then(blob => createImageBitmap(blob).then(() => true, () => false))
|
||||
// }
|
||||
// let webp = undefined
|
||||
// const webpPromise = supportsWebp()
|
||||
// webpPromise.then(x => (webp = x))
|
||||
// export default () => {
|
||||
// if (webp === undefined) return webpPromise
|
||||
// return {
|
||||
// then: callback => callback(webp),
|
||||
// }
|
||||
// }
|
||||
|
||||
const detectWebpSupport = () => {
|
||||
if (ssr) return false;
|
||||
const elem = document.createElement('canvas');
|
||||
if (elem.getContext && elem.getContext('2d')) {
|
||||
// was able or not to get WebP representation
|
||||
return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
||||
} else {
|
||||
// very old browser like IE 8, canvas not supported
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const supportsWebp = detectWebpSupport();
|
||||
|
||||
const isWebp = (x) =>
|
||||
x.format === 'webp' || (x.src && x.src.match(/\.webp($|\?.*)/i));
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
export const selectSrc = ({srcSet, maxImageWidth, supportsWebp}) => {
|
||||
if (srcSet.length === 0) throw new Error('Need at least one item in srcSet');
|
||||
let supportedFormat, width;
|
||||
if (supportsWebp) {
|
||||
supportedFormat = srcSet.filter(isWebp);
|
||||
if (supportedFormat.length === 0) supportedFormat = srcSet;
|
||||
} else {
|
||||
supportedFormat = srcSet.filter((x) => !isWebp(x));
|
||||
if (supportedFormat.length === 0)
|
||||
throw new Error('Need at least one supported format item in srcSet');
|
||||
}
|
||||
let widths = supportedFormat.filter((x) => x.width >= maxImageWidth);
|
||||
if (widths.length === 0) {
|
||||
widths = supportedFormat;
|
||||
width = Math.max.apply(
|
||||
null,
|
||||
widths.map((x) => x.width),
|
||||
);
|
||||
} else {
|
||||
width = Math.min.apply(
|
||||
null,
|
||||
widths.map((x) => x.width),
|
||||
);
|
||||
}
|
||||
return supportedFormat.filter((x) => x.width === width)[0];
|
||||
};
|
||||
|
||||
export const fallbackParams = ({srcSet, getUrl}) => {
|
||||
if (!ssr) return {};
|
||||
const notWebp = srcSet.filter((x) => !isWebp(x));
|
||||
const first = notWebp[0];
|
||||
return {
|
||||
nsSrcSet: notWebp
|
||||
.map((x) => `${getUrl ? getUrl(x) : x.src} ${x.width}w`)
|
||||
.join(','),
|
||||
nsSrc: getUrl ? getUrl(first) : first.src,
|
||||
ssr,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
import DownloadIcon from './Icon/Download';
|
||||
import OfflineIcon from './Icon/Offline';
|
||||
import WarningIcon from './Icon/Warning';
|
||||
import LoadingIcon from './Icon/Loading';
|
||||
import {icons} from './constants';
|
||||
|
||||
const {load, loading, loaded, error, noicon, offline} = icons;
|
||||
|
||||
export default {
|
||||
[load]: DownloadIcon,
|
||||
[loading]: LoadingIcon,
|
||||
[loaded]: null,
|
||||
[error]: WarningIcon,
|
||||
[noicon]: null,
|
||||
[offline]: OfflineIcon,
|
||||
};
|
|
@ -0,0 +1,112 @@
|
|||
// There is an issue with cancelable interface
|
||||
// It is not obvious that
|
||||
// `image(src)` has `cancel` function
|
||||
// but `image(src).then()` doesn't
|
||||
|
||||
import {unfetch, UnfetchAbortController} from './unfetch';
|
||||
|
||||
/**
|
||||
* returns new "promise" with cancel function combined
|
||||
*
|
||||
* @param {Promise} p1 - first "promise" with cancel
|
||||
* @param {Promise} p2 - second "promise" with cancel
|
||||
* @returns {Promise} - new "promise" with cancel
|
||||
*/
|
||||
export const combineCancel = (p1, p2) => {
|
||||
if (!p2) return p1;
|
||||
const result = p1.then(
|
||||
(x) => x,
|
||||
(x) => x,
|
||||
);
|
||||
result.cancel = () => {
|
||||
p1.cancel();
|
||||
p2.cancel();
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
export const timeout = (threshold) => {
|
||||
let timerId;
|
||||
const result = new Promise((resolve) => {
|
||||
timerId = setTimeout(resolve, threshold);
|
||||
});
|
||||
result.cancel = () => {
|
||||
// there is a bug with cancel somewhere in the code
|
||||
// if (!timerId) throw new Error('Already canceled')
|
||||
clearTimeout(timerId);
|
||||
timerId = undefined;
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
// Caveat: image loader can not cancel download in some browsers
|
||||
export const imageLoader = (src) => {
|
||||
let img = new Image();
|
||||
const result = new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
// eslint-disable-next-line no-multi-assign
|
||||
img.onabort = img.onerror = () => reject({});
|
||||
img.src = src;
|
||||
});
|
||||
result.cancel = () => {
|
||||
if (!img) throw new Error('Already canceled');
|
||||
// eslint-disable-next-line no-multi-assign
|
||||
img.onload = img.onabort = img.onerror = undefined;
|
||||
img.src = '';
|
||||
img = undefined;
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
// Caveat: XHR loader can cause errors because of 'Access-Control-Allow-Origin'
|
||||
// Caveat: we still need imageLoader to do actual decoding,
|
||||
// but if images are uncachable this will lead to two requests
|
||||
export const xhrLoader = (url, options) => {
|
||||
let controller = new UnfetchAbortController();
|
||||
const signal = controller.signal;
|
||||
const result = new Promise((resolve, reject) =>
|
||||
unfetch(url, {...options, signal}).then((response) => {
|
||||
if (response.ok) {
|
||||
response
|
||||
.blob()
|
||||
.then(() => imageLoader(url))
|
||||
.then(resolve);
|
||||
} else {
|
||||
reject({status: response.status});
|
||||
}
|
||||
}, reject),
|
||||
);
|
||||
result.cancel = () => {
|
||||
if (!controller) throw new Error('Already canceled');
|
||||
controller.abort();
|
||||
controller = undefined;
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
// Caveat: AbortController only supported in Chrome 66+
|
||||
// Caveat: we still need imageLoader to do actual decoding,
|
||||
// but if images are uncachable this will lead to two requests
|
||||
// export const fetchLoader = (url, options) => {
|
||||
// let controller = new AbortController()
|
||||
// const signal = controller.signal
|
||||
// const result = new Promise((resolve, reject) =>
|
||||
// fetch(url, {...options, signal}).then(response => {
|
||||
// if (response.ok) {
|
||||
// options && options.onMeta && options.onMeta(response.headers)
|
||||
// response
|
||||
// .blob()
|
||||
// .then(() => imageLoader(url))
|
||||
// .then(resolve)
|
||||
// } else {
|
||||
// reject({status: response.status})
|
||||
// }
|
||||
// }, reject),
|
||||
// )
|
||||
// result.cancel = () => {
|
||||
// if (!controller) throw new Error('Already canceled')
|
||||
// controller.abort()
|
||||
// controller = undefined
|
||||
// }
|
||||
// return result
|
||||
// }
|
|
@ -0,0 +1,26 @@
|
|||
export default {
|
||||
placeholder: {
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative',
|
||||
},
|
||||
img: {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
/* TODO: fix bug in styles */
|
||||
marginBottom: '-4px',
|
||||
},
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
noscript: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.placeholder {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
/* TODO: fix bug in styles */
|
||||
margin-bottom: -4px;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noscript {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
export class UnfetchAbortController {
|
||||
constructor() {
|
||||
this.signal = {onabort: () => {}};
|
||||
this.abort = () => this.signal.onabort();
|
||||
}
|
||||
}
|
||||
|
||||
// modified version of https://github.com/developit/unfetch
|
||||
// - ponyfill intead of polyfill
|
||||
// - add support for AbortController
|
||||
export const unfetch = (url, options) => {
|
||||
options = options || {};
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
|
||||
request.open(options.method || 'get', url, true);
|
||||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const i in options.headers) {
|
||||
request.setRequestHeader(i, options.headers[i]);
|
||||
}
|
||||
|
||||
request.withCredentials = options.credentials === 'include';
|
||||
|
||||
request.onload = () => {
|
||||
resolve(response());
|
||||
};
|
||||
|
||||
request.onerror = reject;
|
||||
|
||||
if (options.signal)
|
||||
options.signal.onabort = () => {
|
||||
// eslint-disable-next-line no-multi-assign
|
||||
request.onerror = request.onload = undefined;
|
||||
request.abort();
|
||||
};
|
||||
|
||||
request.send(options.body);
|
||||
|
||||
function response() {
|
||||
const keys = [];
|
||||
const all = [];
|
||||
const headers = {};
|
||||
let header;
|
||||
|
||||
request
|
||||
.getAllResponseHeaders()
|
||||
.replace(/^(.*?):\s*?([\s\S]*?)$/gm, (m, key, value) => {
|
||||
keys.push((key = key.toLowerCase()));
|
||||
all.push([key, value]);
|
||||
header = headers[key];
|
||||
headers[key] = header ? `${header},${value}` : value;
|
||||
});
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
ok: ((request.status / 100) | 0) === 2, // 200-299
|
||||
status: request.status,
|
||||
statusText: request.statusText,
|
||||
url: request.responseURL,
|
||||
clone: response,
|
||||
text: () => Promise.resolve(request.responseText),
|
||||
json: () => Promise.resolve(request.responseText).then(JSON.parse),
|
||||
blob: () => Promise.resolve(new Blob([request.response])),
|
||||
headers: {
|
||||
keys: () => keys,
|
||||
entries: () => all,
|
||||
get: (n) => headers[n.toLowerCase()],
|
||||
has: (n) => n.toLowerCase() in headers,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import IdealImageWithDefaults from './components/IdealImageWithDefaults';
|
||||
|
||||
export default IdealImageWithDefaults;
|
|
@ -3365,11 +3365,6 @@
|
|||
dependencies:
|
||||
"@sinonjs/commons" "^3.0.0"
|
||||
|
||||
"@slorber/react-ideal-image@^0.0.14":
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@slorber/react-ideal-image/-/react-ideal-image-0.0.14.tgz#35b0756c6f06ec60c4a2b5cae9dcf346500e1e8a"
|
||||
integrity sha512-ULJ1VtNg+B5puJp4ZQzEnDqYyDT9erbABoQygmAovg35ltOymLMH8jXPuxJQBVskcmaG29bTZ+++hE/PAXRgxA==
|
||||
|
||||
"@slorber/remark-comment@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@slorber/remark-comment/-/remark-comment-1.0.0.tgz#2a020b3f4579c89dec0361673206c28d67e08f5a"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue