diff --git a/src/plugins/files/public/components/file_picker/components/file_card.tsx b/src/plugins/files/public/components/file_picker/components/file_card.tsx index 31153312577c5..a50e031c1c25b 100644 --- a/src/plugins/files/public/components/file_picker/components/file_card.tsx +++ b/src/plugins/files/public/components/file_picker/components/file_card.tsx @@ -59,6 +59,7 @@ export const FileCard: FunctionComponent = ({ file }) => { `} meta={file.meta as FileImageMetadata} src={client.getDownloadHref({ id: file.id, fileKind: kind })} + loading={'lazy'} /> ) : (
= ({ - visible, - hash, - width, - height, - isContainerWidth, -}) => { - const ref = useRef(null); - const { euiTheme } = useEuiTheme(); - useEffect(() => { - try { - const { width: blurWidth, height: blurHeight } = fitToBox(width, height); - const canvas = document.createElement('canvas'); - canvas.width = blurWidth; - canvas.height = blurHeight; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(blurWidth, blurHeight); - imageData.data.set(decode(hash, blurWidth, blurHeight)); - ctx.putImageData(imageData, 0, 0); - ref.current!.src = canvas.toDataURL(); - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } - }, [hash, width, height]); - return ( - - ); -}; diff --git a/src/plugins/files/public/components/image/components/img.tsx b/src/plugins/files/public/components/image/components/img.tsx deleted file mode 100644 index 855d4547058a5..0000000000000 --- a/src/plugins/files/public/components/image/components/img.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import type { ImgHTMLAttributes, MutableRefObject } from 'react'; -import type { EuiImageSize } from '@elastic/eui/src/components/image/image_types'; -import { useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { sizes } from '../styles'; - -export interface Props extends ImgHTMLAttributes { - size?: EuiImageSize; - hidden: boolean; - observerRef: (el: null | HTMLImageElement) => void; -} - -export const Img = React.forwardRef( - ({ observerRef, src, size, hidden, ...rest }, ref) => { - const { euiTheme } = useEuiTheme(); - const styles = [ - css` - transition: opacity ${euiTheme.animation.extraFast}; - `, - hidden - ? css` - visibility: hidden; - ` - : undefined, - !src - ? css` - position: absolute; // ensure that empty img tag occupies full container - top: 0; - right: 0; - bottom: 0; - left: 0; - ` - : undefined, - size ? sizes[size] : undefined, - ]; - return ( - { - observerRef(element); - if (ref) { - if (typeof ref === 'function') ref(element); - else (ref as MutableRefObject).current = element; - } - }} - /> - ); - } -); diff --git a/src/plugins/files/public/components/image/components/index.ts b/src/plugins/files/public/components/image/components/index.ts deleted file mode 100644 index bae3c92eab517..0000000000000 --- a/src/plugins/files/public/components/image/components/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { Img } from './img'; -export type { Props as ImgProps } from './img'; -export { Blurhash } from './blurhash'; diff --git a/src/plugins/files/public/components/image/image.stories.tsx b/src/plugins/files/public/components/image/image.stories.tsx index d26a74470bdca..15967437d0849 100644 --- a/src/plugins/files/public/components/image/image.stories.tsx +++ b/src/plugins/files/public/components/image/image.stories.tsx @@ -8,14 +8,10 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; -import { css } from '@emotion/react'; -import { FilesContext } from '../context'; import { getImageMetadata } from '../util'; import { Image, Props } from './image'; import { getImageData as getBlob, base64dLogo } from './image.constants.stories'; -import { FilesClient } from '../../types'; const defaultArgs: Props = { alt: 'test', src: `data:image/png;base64,${base64dLogo}` }; @@ -24,41 +20,38 @@ export default { component: Image, args: defaultArgs, decorators: [ - (Story) => ( - - - - ), + (Story) => { + React.useLayoutEffect(() => { + // @ts-ignore + window.__image_stories_simulate_slow_load = true; + return () => { + // @ts-ignore + window.__image_stories_simulate_slow_load = false; + }; + }, []); + + return ( + <> + + + ); + }, ], } as ComponentMeta; const Template: ComponentStory = (props: Props, { loaded: { meta } }) => ( - + ); export const Basic = Template.bind({}); export const WithBlurhash = Template.bind({}); WithBlurhash.storyName = 'With blurhash'; -WithBlurhash.args = { - style: { visibility: 'hidden' }, -}; WithBlurhash.loaders = [ async () => ({ meta: await getImageMetadata(getBlob()), }), ]; -WithBlurhash.decorators = [ - (Story) => { - const alwaysShowBlurhash = `img:nth-of-type(1) { opacity: 1 !important; }`; - return ( - <> - - - - ); - }, -]; export const BrokenSrc = Template.bind({}); BrokenSrc.storyName = 'Broken src'; @@ -71,26 +64,20 @@ WithBlurhashAndBrokenSrc.storyName = 'With blurhash and broken src'; WithBlurhashAndBrokenSrc.args = { src: 'foo', }; + WithBlurhashAndBrokenSrc.loaders = [ async () => ({ blurhash: await getImageMetadata(getBlob()), }), ]; -export const OffScreen = Template.bind({}); -OffScreen.storyName = 'Offscreen'; -OffScreen.args = { onFirstVisible: action('visible') }; -OffScreen.decorators = [ - (Story) => ( - <> -

Scroll down

-
- -
- - ), +export const WithCustomSizing = Template.bind({}); +WithCustomSizing.storyName = 'With custom sizing'; +WithCustomSizing.loaders = [ + async () => ({ + meta: await getImageMetadata(getBlob()), + }), ]; +WithCustomSizing.args = { + css: `width: 100px; height: 500px; object-fit: fill`, +}; diff --git a/src/plugins/files/public/components/image/image.tsx b/src/plugins/files/public/components/image/image.tsx index e2cb78d910e3d..dc0ffe6cf0e51 100644 --- a/src/plugins/files/public/components/image/image.tsx +++ b/src/plugins/files/public/components/image/image.tsx @@ -6,104 +6,64 @@ * Side Public License, v 1. */ -import React, { HTMLAttributes } from 'react'; -import { type ImgHTMLAttributes, useState, useEffect } from 'react'; -import { css } from '@emotion/react'; +import React from 'react'; +import { useState } from 'react'; +import { EuiImage, EuiImageProps } from '@elastic/eui'; import type { FileImageMetadata } from '../../../common'; -import { useViewportObserver } from './use_viewport_observer'; -import { Img, type ImgProps, Blurhash } from './components'; -import { sizes } from './styles'; +import { getBlurhashSrc } from '../util'; -export interface Props extends ImgHTMLAttributes { - src: string; - alt: string; - /** - * Image metadata - */ - meta?: FileImageMetadata; - - /** - * @default original - */ - size?: ImgProps['size']; - /** - * Props to pass to the wrapper element - */ - wrapperProps?: HTMLAttributes; - /** - * Emits when the image first becomes visible - */ - onFirstVisible?: () => void; -} +export type Props = { meta?: FileImageMetadata } & EuiImageProps; /** - * A viewport-aware component that displays an image. This component is a very - * thin wrapper around the img tag. + * A wrapper around the that can renders blurhash by the file service while the image is loading * * @note Intended to be used with files like: * * ```ts - * + * * ``` */ -export const Image = React.forwardRef( - ( - { src, alt, onFirstVisible, onLoad, onError, meta, wrapperProps, size = 'original', ...rest }, - ref - ) => { - const [isLoaded, setIsLoaded] = useState(false); - const [blurDelayExpired, setBlurDelayExpired] = useState(false); - const { isVisible, ref: observerRef } = useViewportObserver({ onFirstVisible }); +export const Image = ({ src, url, alt, onLoad, onError, meta, ...rest }: Props) => { + const imageSrc = (src || url)!; // allows to use either `src` or `url` - useEffect(() => { - const id = window.setTimeout(() => { - setBlurDelayExpired(true); - }, 200); - return () => { - window.clearTimeout(id); - }; - }, []); + const [isBlurHashLoaded, setIsBlurHashLoaded] = useState(false); + const { blurhash, width, height } = meta ?? {}; + const blurhashSrc = React.useMemo( + () => (blurhash && width && height ? getBlurhashSrc({ hash: blurhash, width, height }) : null), + [blurhash, width, height] + ); - const knownSize = size ? sizes[size] : undefined; + // prettier-ignore + const currentSrc = (isBlurHashLoaded || !blurhashSrc) ? imageSrc : blurhashSrc - return ( -
- {blurDelayExpired && meta?.blurhash && ( - - )} - { - setIsLoaded(true); - onLoad?.(ev); - }} - onError={(ev) => { - setIsLoaded(true); - onError?.(ev); - }} - {...rest} - /> -
- ); - } -); + return ( + { + // if the `meta.blurhash` is passed, then the component first renders the blurhash and the `onLoad` event fires for the first time, + // In the event handler we call `onBlurHashLoaded` so that the `currentSrc` is swapped with the url to the original image. + // When the onLoad event fires for the 2nd time (as the original image is finished loading) + // we notify the parent component by calling `onLoad` from props + if (currentSrc === imageSrc) { + onLoad?.(ev); + } else { + // @ts-ignore + if (window?.__image_stories_simulate_slow_load) { + // hack for storybook blurhash testing + setTimeout(() => { + setIsBlurHashLoaded(true); + }, 3000); + } else { + setIsBlurHashLoaded(true); + } + } + }} + onError={(ev) => { + onError?.(ev); + }} + /> + ); +}; diff --git a/src/plugins/files/public/components/image/styles.ts b/src/plugins/files/public/components/image/styles.ts deleted file mode 100644 index d69580bcb51a5..0000000000000 --- a/src/plugins/files/public/components/image/styles.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { css } from '@emotion/react'; - -// Values taken from @elastic/eui/src/components/image -export const sizes = { - s: css` - width: 100px; - `, - m: css` - width: 200px; - `, - l: css` - width: 360px; - `, - xl: css` - width: 600px; - `, - original: css` - width: auto; - `, - fullWidth: css` - width: 100%; - `, -}; diff --git a/src/plugins/files/public/components/image/use_viewport_observer.ts b/src/plugins/files/public/components/image/use_viewport_observer.ts deleted file mode 100644 index 6e43cc9d124f6..0000000000000 --- a/src/plugins/files/public/components/image/use_viewport_observer.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Subscription } from 'rxjs'; -import { createViewportObserver } from './viewport_observer'; - -interface Args { - onFirstVisible?: () => void; -} - -export function useViewportObserver({ onFirstVisible }: Args = {}) { - const [isVisible, setIsVisible] = useState(false); - const [viewportObserver] = useState(() => createViewportObserver()); - const subscriptionRef = useRef(); - const ref = useCallback( - (element: null | HTMLElement) => { - if (element && !subscriptionRef.current) { - subscriptionRef.current = viewportObserver.observeElement(element).subscribe(() => { - setIsVisible(true); - onFirstVisible?.(); - }); - } - }, - [viewportObserver, onFirstVisible] - ); - useEffect(() => () => subscriptionRef.current?.unsubscribe(), []); - return { - isVisible, - ref, - }; -} diff --git a/src/plugins/files/public/components/image/viewport_observer.test.ts b/src/plugins/files/public/components/image/viewport_observer.test.ts deleted file mode 100644 index f5dafb25fc724..0000000000000 --- a/src/plugins/files/public/components/image/viewport_observer.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { TestScheduler } from 'rxjs/testing'; -import { ViewportObserver } from './viewport_observer'; - -class MockIntersectionObserver implements IntersectionObserver { - constructor(public callback: IntersectionObserverCallback, opts?: IntersectionObserverInit) {} - disconnect = jest.fn(); - root = null; - rootMargin = ''; - takeRecords = jest.fn(); - thresholds = []; - observe = jest.fn(); - unobserve = jest.fn(); -} - -describe('ViewportObserver', () => { - let viewportObserver: ViewportObserver; - let mockObserver: MockIntersectionObserver; - function getTestScheduler() { - return new TestScheduler((actual, expected) => { - expect(actual).toEqual(expected); - }); - } - beforeEach(() => { - viewportObserver = new ViewportObserver((cb, opts) => { - const mo = new MockIntersectionObserver(cb, opts); - mockObserver = mo; - return mo; - }); - }); - afterEach(() => { - jest.resetAllMocks(); - }); - - test('only observes one element per instance', () => { - viewportObserver.observeElement(document.createElement('div')); - viewportObserver.observeElement(document.createElement('div')); - viewportObserver.observeElement(document.createElement('div')); - viewportObserver.observeElement(document.createElement('div')); - expect(mockObserver.observe).toHaveBeenCalledTimes(1); - }); - - test('emits only once', () => { - expect.assertions(2); - getTestScheduler().run(({ expectObservable }) => { - const observe$ = viewportObserver.observeElement(document.createElement('div')); - mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver); - mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver); - mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver); - mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver); - expectObservable(observe$).toBe('(a|)', { a: undefined }); - expect(mockObserver.disconnect).toHaveBeenCalledTimes(4); - }); - }); -}); diff --git a/src/plugins/files/public/components/image/viewport_observer.ts b/src/plugins/files/public/components/image/viewport_observer.ts deleted file mode 100644 index 165af5aecb98c..0000000000000 --- a/src/plugins/files/public/components/image/viewport_observer.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { once } from 'lodash'; -import { Observable, ReplaySubject } from 'rxjs'; -import { take } from 'rxjs/operators'; - -/** - * Check whether an element is visible and emit, only once, when it intersects - * with the viewport. - */ -export class ViewportObserver { - private readonly intersectionObserver: IntersectionObserver; - private readonly intersection$ = new ReplaySubject(1); - - /** - * @param getIntersectionObserver Inject the intersection observer as a dependency. - */ - constructor( - getIntersectionObserver: ( - cb: IntersectionObserverCallback, - opts: IntersectionObserverInit - ) => IntersectionObserver - ) { - this.intersectionObserver = getIntersectionObserver(this.handleChange, { - rootMargin: '0px', - root: null, - }); - } - - /** - * Call this function to start observing. - * - * It is callable once only per instance and will emit only once: when an - * element's bounding rect intersects with the viewport. - */ - public observeElement = once((element: HTMLElement): Observable => { - this.intersectionObserver.observe(element); - return this.intersection$.pipe(take(1)); - }); - - private handleChange = ([{ isIntersecting }]: IntersectionObserverEntry[]) => { - if (isIntersecting) { - this.intersection$.next(undefined); - this.intersectionObserver.disconnect(); - } - }; -} - -export function createViewportObserver(): ViewportObserver { - return new ViewportObserver((cb, opts) => new IntersectionObserver(cb, opts)); -} diff --git a/src/plugins/files/public/components/util/image_metadata.test.ts b/src/plugins/files/public/components/util/image_metadata.test.ts index de4cba5c82dc9..d91b9be36f6bb 100644 --- a/src/plugins/files/public/components/util/image_metadata.test.ts +++ b/src/plugins/files/public/components/util/image_metadata.test.ts @@ -12,8 +12,8 @@ describe('util', () => { test('300x300', () => { expect(fitToBox(300, 300)).toMatchInlineSnapshot(` Object { - "height": 300, - "width": 300, + "height": 120, + "width": 120, } `); }); @@ -21,8 +21,8 @@ describe('util', () => { test('300x150', () => { expect(fitToBox(300, 150)).toMatchInlineSnapshot(` Object { - "height": 150, - "width": 300, + "height": 60, + "width": 120, } `); }); @@ -30,8 +30,8 @@ describe('util', () => { test('4500x9000', () => { expect(fitToBox(4500, 9000)).toMatchInlineSnapshot(` Object { - "height": 300, - "width": 150, + "height": 120, + "width": 60, } `); }); @@ -39,8 +39,8 @@ describe('util', () => { test('1000x300', () => { expect(fitToBox(1000, 300)).toMatchInlineSnapshot(` Object { - "height": 90, - "width": 300, + "height": 36, + "width": 120, } `); }); diff --git a/src/plugins/files/public/components/util/image_metadata.ts b/src/plugins/files/public/components/util/image_metadata.ts index 75a42efed585c..b1faed7b294b8 100644 --- a/src/plugins/files/public/components/util/image_metadata.ts +++ b/src/plugins/files/public/components/util/image_metadata.ts @@ -14,8 +14,8 @@ export function isImage(file: { type?: string }): boolean { } export const boxDimensions = { - width: 300, - height: 300, + width: 120, + height: 120, }; /** @@ -57,6 +57,8 @@ export async function getImageMetadata(file: File | Blob): Promise` tag + const originalSizeImageCanvas = document.createElement('canvas'); + originalSizeImageCanvas.width = width; + originalSizeImageCanvas.height = height; + const originalSizeImageCtx = originalSizeImageCanvas.getContext('2d')!; + originalSizeImageCtx.drawImage(smallSizeImageCanvas, 0, 0, width, height); + return originalSizeImageCanvas.toDataURL(); +} diff --git a/src/plugins/files/public/components/util/index.ts b/src/plugins/files/public/components/util/index.ts index 5c25ffd636a5c..9e0dccc6c3365 100644 --- a/src/plugins/files/public/components/util/index.ts +++ b/src/plugins/files/public/components/util/index.ts @@ -6,5 +6,5 @@ * Side Public License, v 1. */ -export { getImageMetadata, isImage, fitToBox } from './image_metadata'; +export { getImageMetadata, isImage, fitToBox, getBlurhashSrc } from './image_metadata'; export type { ImageMetadataFactory } from './image_metadata';