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:
Sébastien Lorber 2025-03-19 18:21:09 +01:00 committed by GitHub
parent 502b9007be
commit fcee060f40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1717 additions and 133 deletions

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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}}

View file

@ -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.

View file

@ -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

View file

@ -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>
`;

View file

@ -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}`);
}
});
});

View file

@ -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({});
});
});
*/

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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>
);
}
}

View file

@ -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;

View file

@ -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>
);
}
}

View file

@ -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>;
```

View file

@ -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;

View file

@ -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,
};
};

View file

@ -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,
};

View file

@ -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,
};
};

View file

@ -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,
};

View file

@ -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
// }

View file

@ -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,
},
};

View file

@ -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;
}

View file

@ -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,
},
};
}
});
};

View file

@ -0,0 +1,3 @@
import IdealImageWithDefaults from './components/IdealImageWithDefaults';
export default IdealImageWithDefaults;

View file

@ -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"