mirror of
https://github.com/facebook/docusaurus.git
synced 2025-12-25 17:22:50 +00:00
refactor(ideal-image-plugin): internalize legacy component code (#11010)
Some checks are pending
Argos CI / take-screenshots (push) Waiting to run
Build Hash Router / Build Hash Router (push) Waiting to run
Canary Release / Publish Canary (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
Continuous Releases / Continuous Releases (push) Waiting to run
E2E Tests / E2E — Yarn v1 (18.0) (push) Waiting to run
E2E Tests / E2E — Yarn v1 (20) (push) Waiting to run
E2E Tests / E2E — Yarn v1 (22) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (node-modules, -s) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (node-modules, -st) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (pnp, -s) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (pnp, -st) (push) Waiting to run
E2E Tests / E2E — npm (push) Waiting to run
E2E Tests / E2E — pnpm (push) Waiting to run
Some checks are pending
Argos CI / take-screenshots (push) Waiting to run
Build Hash Router / Build Hash Router (push) Waiting to run
Canary Release / Publish Canary (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
Continuous Releases / Continuous Releases (push) Waiting to run
E2E Tests / E2E — Yarn v1 (18.0) (push) Waiting to run
E2E Tests / E2E — Yarn v1 (20) (push) Waiting to run
E2E Tests / E2E — Yarn v1 (22) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (node-modules, -s) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (node-modules, -st) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (pnp, -s) (push) Waiting to run
E2E Tests / E2E — Yarn Berry (pnp, -st) (push) Waiting to run
E2E Tests / E2E — npm (push) Waiting to run
E2E Tests / E2E — pnpm (push) Waiting to run
* 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
|
|
@ -23,6 +23,7 @@
|
||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"patches",
|
"patches",
|
||||||
"packages/docusaurus-theme-translations/locales",
|
"packages/docusaurus-theme-translations/locales",
|
||||||
|
"packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy",
|
||||||
"package.json",
|
"package.json",
|
||||||
"yarn.lock",
|
"yarn.lock",
|
||||||
"project-words.txt",
|
"project-words.txt",
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,6 @@ packages/create-docusaurus/templates/facebook
|
||||||
|
|
||||||
website/_dogfooding/_swizzle_theme_tests
|
website/_dogfooding/_swizzle_theme_tests
|
||||||
website/_dogfooding/_asset-tests/badSyntax.js
|
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/theme-translations": "3.7.0",
|
||||||
"@docusaurus/types": "3.7.0",
|
"@docusaurus/types": "3.7.0",
|
||||||
"@docusaurus/utils-validation": "3.7.0",
|
"@docusaurus/utils-validation": "3.7.0",
|
||||||
"@slorber/react-ideal-image": "^0.0.14",
|
|
||||||
"react-waypoint": "^10.3.0",
|
"react-waypoint": "^10.3.0",
|
||||||
"sharp": "^0.32.3",
|
"sharp": "^0.32.3",
|
||||||
"tslib": "^2.6.0",
|
"tslib": "^2.6.0",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/// <reference types="@docusaurus/module-type-aliases" />
|
||||||
|
|
||||||
declare module '@docusaurus/plugin-ideal-image' {
|
declare module '@docusaurus/plugin-ideal-image' {
|
||||||
export type PluginOptions = {
|
export type PluginOptions = {
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,3 +76,122 @@ declare module '@theme/IdealImage' {
|
||||||
}
|
}
|
||||||
export default function IdealImage(props: Props): ReactNode;
|
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 React, {type ReactNode} from 'react';
|
||||||
|
import {translate} from '@docusaurus/Translate';
|
||||||
import ReactIdealImage, {
|
import ReactIdealImage, {
|
||||||
type IconKey,
|
type IconKey,
|
||||||
type State,
|
type State,
|
||||||
} from '@slorber/react-ideal-image';
|
} from '@theme/IdealImageLegacy';
|
||||||
import {translate} from '@docusaurus/Translate';
|
|
||||||
|
|
||||||
import type {Props} from '@theme/IdealImage';
|
import type {Props} from '@theme/IdealImage';
|
||||||
|
|
||||||
|
|
@ -93,7 +93,6 @@ export default function IdealImage(props: Props): ReactNode {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactIdealImage
|
<ReactIdealImage
|
||||||
{...propsRest}
|
|
||||||
height={img.src.height ?? 100}
|
height={img.src.height ?? 100}
|
||||||
width={img.src.width ?? 100}
|
width={img.src.width ?? 100}
|
||||||
placeholder={{lqip: img.preSrc}}
|
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:
|
dependencies:
|
||||||
"@sinonjs/commons" "^3.0.0"
|
"@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":
|
"@slorber/remark-comment@^1.0.0":
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@slorber/remark-comment/-/remark-comment-1.0.0.tgz#2a020b3f4579c89dec0361673206c28d67e08f5a"
|
resolved "https://registry.yarnpkg.com/@slorber/remark-comment/-/remark-comment-1.0.0.tgz#2a020b3f4579c89dec0361673206c28d67e08f5a"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue