diff --git a/.cspell.json b/.cspell.json index f55f6265d1..efddcf2a00 100644 --- a/.cspell.json +++ b/.cspell.json @@ -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", diff --git a/.eslintignore b/.eslintignore index 6273a95b15..6aa6a0b9f8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/packages/docusaurus-plugin-ideal-image/package.json b/packages/docusaurus-plugin-ideal-image/package.json index 8374d3549d..b67f2377bc 100644 --- a/packages/docusaurus-plugin-ideal-image/package.json +++ b/packages/docusaurus-plugin-ideal-image/package.json @@ -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", diff --git a/packages/docusaurus-plugin-ideal-image/src/deps.d.ts b/packages/docusaurus-plugin-ideal-image/src/deps.d.ts deleted file mode 100644 index 13f80f4841..0000000000 --- a/packages/docusaurus-plugin-ideal-image/src/deps.d.ts +++ /dev/null @@ -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. - */ - -/// - -/** - * @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, '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; -} diff --git a/packages/docusaurus-plugin-ideal-image/src/plugin-ideal-image.d.ts b/packages/docusaurus-plugin-ideal-image/src/plugin-ideal-image.d.ts index 28191e9816..4739e8a3e8 100644 --- a/packages/docusaurus-plugin-ideal-image/src/plugin-ideal-image.d.ts +++ b/packages/docusaurus-plugin-ideal-image/src/plugin-ideal-image.d.ts @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +/// + 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, '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; +} diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImage/index.tsx b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImage/index.tsx index a983927ec4..7cc1e0e619 100644 --- a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImage/index.tsx +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImage/index.tsx @@ -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 ( + + + +`; + +exports[`Loading icon Should render a snapshot that is good 1`] = ` + + + + +`; + +exports[`Offline icon Should render a snapshot that is good 1`] = ` + + + + +`; + +exports[`Warning icon Should render a snapshot that is good 1`] = ` + + + + +`; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/composeStyle.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/composeStyle.js new file mode 100644 index 0000000000..c70bcaa2fb --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/composeStyle.js @@ -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}`); + } + }); +}); diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/helpers.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/helpers.js new file mode 100644 index 0000000000..f3fac03a39 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/helpers.js @@ -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({}); + }); +}); + */ diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/helpers.node.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/helpers.node.js new file mode 100644 index 0000000000..bb9b39a321 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/helpers.node.js @@ -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); + }); +}); diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/icon.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/icon.js new file mode 100644 index 0000000000..980243b317 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/__tests__/icon.js @@ -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().toJSON(); + expect(download).toMatchSnapshot(); + }); +}); + +describe('Loading icon', () => { + it(snapshotTestDescription, () => { + const loading = renderer.create().toJSON(); + expect(loading).toMatchSnapshot(); + }); +}); + +describe('Offline icon', () => { + it(snapshotTestDescription, () => { + const offline = renderer.create().toJSON(); + expect(offline).toMatchSnapshot(); + }); +}); + +describe('Warning icon', () => { + it(snapshotTestDescription, () => { + const warning = renderer.create().toJSON(); + expect(warning).toMatchSnapshot(); + }); +}); diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Download.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Download.js new file mode 100644 index 0000000000..ac9876766a --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Download.js @@ -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) => ( + +); + +export default Download; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Loading.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Loading.js new file mode 100644 index 0000000000..dbc8d05e61 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Loading.js @@ -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) => ( + +); + +export default Loading; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Offline.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Offline.js new file mode 100644 index 0000000000..b704f74c61 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Offline.js @@ -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) => ( + +); + +export default Offline; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Warning.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Warning.js new file mode 100644 index 0000000000..3bee0034c6 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/Warning.js @@ -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) => ( + +); + +export default Warning; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/index.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/index.js new file mode 100644 index 0000000000..11593b154e --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Icon/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +// import PropTypes from 'prop-types' + +const Icon = ({size = 24, fill = '#000', className, path}) => ( + + + + +); + +/* +Icon.propTypes = { + size: PropTypes.number, + fill: PropTypes.string, + className: PropTypes.string, + path: PropTypes.string.isRequired, +} + */ + +export default Icon; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/IdealImage/index.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/IdealImage/index.js new file mode 100644 index 0000000000..4dfe4a68d9 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/IdealImage/index.js @@ -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 (', + {bytesToSize(size)}, + ')', + ]; + } 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 ( + + this.setState({dimensions})} + message={message} + /> + + ); + } +} diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/IdealImageWithDefaults/index.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/IdealImageWithDefaults/index.js new file mode 100644 index 0000000000..b485fe1c65 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/IdealImageWithDefaults/index.js @@ -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 +}) => ; + +export default IdealImageWithDefaults; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Media/index.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Media/index.js new file mode 100644 index 0000000000..9cea933ecb --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/Media/index.js @@ -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 ? ( + {props.alt} + ) : ( + (this.dimensionElement = ref)} + /> + ); + } + + renderNoscript(props) { + // render noscript in ssr + hydration to avoid hydration mismatch error + return this.state.isMounted ? null : ( + {props.alt} + + ); + } + + 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 ( +
+ {this.renderImage(props)} + {this.renderNoscript(props)} + {this.renderIcon(props)} +
+ ); + } +} diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/MediaWithDefaults/README.md b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/MediaWithDefaults/README.md new file mode 100644 index 0000000000..a69473f61e --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/MediaWithDefaults/README.md @@ -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"; + + + + + + + + + + + + + + + + + + + + + +
+ load + + + + noicon + + +
loading + + offline + +
loaded + + error + +
; +``` diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/MediaWithDefaults/index.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/MediaWithDefaults/index.js new file mode 100644 index 0000000000..65e3dab73d --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/MediaWithDefaults/index.js @@ -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 +}) => ; + +export default MediaWithDefaults; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/composeStyle.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/composeStyle.js new file mode 100644 index 0000000000..5adf6fa701 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/composeStyle.js @@ -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 + * + * link + * + * @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, + }; +}; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/constants.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/constants.js new file mode 100644 index 0000000000..328ac72014 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/constants.js @@ -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, +}; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/helpers.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/helpers.js new file mode 100644 index 0000000000..f2c06f1602 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/helpers.js @@ -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, + }; +}; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/icons.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/icons.js new file mode 100644 index 0000000000..309afd59f0 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/icons.js @@ -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, +}; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/loaders.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/loaders.js new file mode 100644 index 0000000000..a0345feaf7 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/loaders.js @@ -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 +// } diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/theme.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/theme.js new file mode 100644 index 0000000000..f967ae3224 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/theme.js @@ -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, + }, +}; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/theme.module.css b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/theme.module.css new file mode 100644 index 0000000000..52fc55af26 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/theme.module.css @@ -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; +} diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/unfetch.js b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/unfetch.js new file mode 100644 index 0000000000..3b5f10b0de --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/components/unfetch.js @@ -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, + }, + }; + } + }); +}; diff --git a/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/index.tsx b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/index.tsx new file mode 100644 index 0000000000..70a74434b4 --- /dev/null +++ b/packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy/index.tsx @@ -0,0 +1,3 @@ +import IdealImageWithDefaults from './components/IdealImageWithDefaults'; + +export default IdealImageWithDefaults; diff --git a/yarn.lock b/yarn.lock index 80d7627d62..781ab1ef61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"