From 8fa55f687a6f21b4bff060c9f86a5a2fc8bc02df Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 16 Nov 2022 13:13:22 +0100 Subject: [PATCH 1/5] Simplify image component implementation and usage --- .../file_picker/components/file_card.tsx | 2 + .../components/image/components/blurhash.tsx | 64 -------- .../components/image/components/img.tsx | 61 -------- .../components/image/components/index.ts | 11 -- .../public/components/image/image.stories.tsx | 58 +++++--- .../files/public/components/image/image.tsx | 140 +++++++----------- .../files/public/components/image/styles.ts | 31 ---- .../components/image/use_viewport_observer.ts | 37 ----- .../image/viewport_observer.test.ts | 62 -------- .../components/image/viewport_observer.ts | 57 ------- .../public/components/util/image_metadata.ts | 24 ++- .../files/public/components/util/index.ts | 2 +- 12 files changed, 111 insertions(+), 438 deletions(-) delete mode 100644 src/plugins/files/public/components/image/components/blurhash.tsx delete mode 100644 src/plugins/files/public/components/image/components/img.tsx delete mode 100644 src/plugins/files/public/components/image/components/index.ts delete mode 100644 src/plugins/files/public/components/image/styles.ts delete mode 100644 src/plugins/files/public/components/image/use_viewport_observer.ts delete mode 100644 src/plugins/files/public/components/image/viewport_observer.test.ts delete mode 100644 src/plugins/files/public/components/image/viewport_observer.ts 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..17f5b3650716f 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 @@ -56,9 +56,11 @@ export const FileCard: FunctionComponent = ({ file }) => { alt={file.alt ?? ''} css={css` max-height: ${imageHeight}; + object-fit: contain; `} 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..1b30d8fb8fca6 100644 --- a/src/plugins/files/public/components/image/image.stories.tsx +++ b/src/plugins/files/public/components/image/image.stories.tsx @@ -8,14 +8,11 @@ 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 +21,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,15 +65,31 @@ WithBlurhashAndBrokenSrc.storyName = 'With blurhash and broken src'; WithBlurhashAndBrokenSrc.args = { src: 'foo', }; + WithBlurhashAndBrokenSrc.loaders = [ async () => ({ blurhash: await getImageMetadata(getBlob()), }), ]; +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`, +}; + export const OffScreen = Template.bind({}); OffScreen.storyName = 'Offscreen'; -OffScreen.args = { onFirstVisible: action('visible') }; +OffScreen.args = { + /* demonstrates how to make image lazy load */ + /* @note: the demo doesn't really work with the base64 encoded src */ + loading: 'lazy', +}; OffScreen.decorators = [ (Story) => ( <> diff --git a/src/plugins/files/public/components/image/image.tsx b/src/plugins/files/public/components/image/image.tsx index e2cb78d910e3d..6f6f22bf0f5ee 100644 --- a/src/plugins/files/public/components/image/image.tsx +++ b/src/plugins/files/public/components/image/image.tsx @@ -6,104 +6,68 @@ * Side Public License, v 1. */ -import React, { HTMLAttributes } from 'react'; -import { type ImgHTMLAttributes, useState, useEffect } from 'react'; -import { css } from '@emotion/react'; +import React, { useMemo } 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 [isBlurHashLoaded, setIsBlurHashLoaded] = useState(false); + + const imageSrc = (src || url)!; // allows to use either `src` or `url` - useEffect(() => { - const id = window.setTimeout(() => { - setBlurDelayExpired(true); - }, 200); - return () => { - window.clearTimeout(id); - }; - }, []); + const { blurhash, width, height } = meta ?? {}; + const blurhashSrc = useMemo( + () => + blurhash && width && height + ? getBlurhashSrc({ + height, + width, + hash: blurhash, + }) + : null, + [blurhash, width, height] + ); - const knownSize = size ? sizes[size] : undefined; + const currentSrc = isBlurHashLoaded || !blurhashSrc ? imageSrc : blurhashSrc; - return ( -
- {blurDelayExpired && meta?.blurhash && ( - - )} - { - setIsLoaded(true); - onLoad?.(ev); - }} - onError={(ev) => { - setIsLoaded(true); - onError?.(ev); - }} - {...rest} - /> -
- ); - } -); + return ( + { + 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.ts b/src/plugins/files/public/components/util/image_metadata.ts index 75a42efed585c..7bb615e29d241 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, }; /** @@ -78,3 +78,23 @@ export async function getImageMetadata(file: File | Blob): Promise Date: Wed, 16 Nov 2022 14:54:30 +0100 Subject: [PATCH 2/5] stretch the blurred image to the original size --- .../files/public/components/image/image.tsx | 62 ++++++++++++------- .../public/components/util/image_metadata.ts | 18 +++++- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/plugins/files/public/components/image/image.tsx b/src/plugins/files/public/components/image/image.tsx index 6f6f22bf0f5ee..e3b2b1396aec5 100644 --- a/src/plugins/files/public/components/image/image.tsx +++ b/src/plugins/files/public/components/image/image.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { useState } from 'react'; import { EuiImage, EuiImageProps } from '@elastic/eui'; import type { FileImageMetadata } from '../../../common'; @@ -24,44 +24,26 @@ export type Props = { meta?: FileImageMetadata } & EuiImageProps; * ``` */ export const Image = ({ src, url, alt, onLoad, onError, meta, ...rest }: Props) => { - const [isBlurHashLoaded, setIsBlurHashLoaded] = useState(false); - const imageSrc = (src || url)!; // allows to use either `src` or `url` - - const { blurhash, width, height } = meta ?? {}; - const blurhashSrc = useMemo( - () => - blurhash && width && height - ? getBlurhashSrc({ - height, - width, - hash: blurhash, - }) - : null, - [blurhash, width, height] - ); - - const currentSrc = isBlurHashLoaded || !blurhashSrc ? imageSrc : blurhashSrc; + const [currentImageSrc, onBlurHashLoaded] = useCurrentImageSrc(imageSrc, meta); return ( { - if (currentSrc === imageSrc) { + if (currentImageSrc === imageSrc) { onLoad?.(ev); } else { // @ts-ignore if (window?.__image_stories_simulate_slow_load) { // hack for storybook blurhash testing setTimeout(() => { - setIsBlurHashLoaded(true); + onBlurHashLoaded(); }, 3000); } else { - setIsBlurHashLoaded(true); + onBlurHashLoaded(); } } }} @@ -71,3 +53,35 @@ export const Image = ({ src, url, alt, onLoad, onError, meta, ...rest }: Props) /> ); }; + +function useCurrentImageSrc( + imageSrc: string, + meta: FileImageMetadata | undefined +): [string, () => void] { + const [isBlurHashLoaded, setIsBlurHashLoaded] = useState(false); + const [blurhashSrc, setBlurHash] = useState(null); + const { blurhash, width, height } = meta ?? {}; + const hasBlurHash = blurhash && width && height; + React.useEffect(() => { + if (blurhash && width && height) { + getBlurhashSrc({ + height, + width, + hash: blurhash, + }).then((hash) => { + setBlurHash(hash); + }); + } else { + setBlurHash(null); + } + }, [blurhash, width, height]); + + const currentSrc = isBlurHashLoaded || !hasBlurHash ? imageSrc : blurhashSrc ? blurhashSrc : null; + + return [ + currentSrc as string, + () => { + setIsBlurHashLoaded(true); + }, + ]; +} diff --git a/src/plugins/files/public/components/util/image_metadata.ts b/src/plugins/files/public/components/util/image_metadata.ts index 7bb615e29d241..e640ccd38cb1f 100644 --- a/src/plugins/files/public/components/util/image_metadata.ts +++ b/src/plugins/files/public/components/util/image_metadata.ts @@ -87,14 +87,28 @@ export function getBlurhashSrc({ width: number; height: number; hash: string; -}) { +}): Promise { const canvas = document.createElement('canvas'); const { width: blurWidth, height: blurHeight } = fitToBox(width, height); canvas.width = blurWidth; canvas.height = blurHeight; + const ctx = canvas.getContext('2d')!; const imageData = ctx.createImageData(blurWidth, blurHeight); imageData.data.set(bh.decode(hash, blurWidth, blurHeight)); ctx.putImageData(imageData, 0, 0); - return canvas.toDataURL(); + + return new Promise((resolve) => { + // On this point canvas contains a downsized blurred image + // Now we have to stretch it to the size of the original image + const image = new Image(); + image.src = canvas.toDataURL(); + image.onload = () => { + canvas.width = width; + canvas.height = height; + ctx.clearRect(0, 0, width, height); + ctx.drawImage(image, 0, 0, width, height); + resolve(canvas.toDataURL()); + }; + }); } From 85f99feecd52e8aa033ffcc0d65ac97b3c852cda Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 16 Nov 2022 14:55:15 +0100 Subject: [PATCH 3/5] update tests --- .../components/util/image_metadata.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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, } `); }); From 351c5ccd2441666833ff4576bb5e1d17a5c25afe Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 16 Nov 2022 15:08:44 +0100 Subject: [PATCH 4/5] remove not needed css --- .../files/public/components/file_picker/components/file_card.tsx | 1 - 1 file changed, 1 deletion(-) 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 17f5b3650716f..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 @@ -56,7 +56,6 @@ export const FileCard: FunctionComponent = ({ file }) => { alt={file.alt ?? ''} css={css` max-height: ${imageHeight}; - object-fit: contain; `} meta={file.meta as FileImageMetadata} src={client.getDownloadHref({ id: file.id, fileKind: kind })} From a7cb59d770cc1079809b0b4c600000beb46d18bf Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 17 Nov 2022 12:59:28 +0100 Subject: [PATCH 5/5] improvements --- .../public/components/image/image.stories.tsx | 23 -------- .../files/public/components/image/image.tsx | 56 +++++++------------ .../public/components/util/image_metadata.ts | 37 ++++++------ 3 files changed, 36 insertions(+), 80 deletions(-) diff --git a/src/plugins/files/public/components/image/image.stories.tsx b/src/plugins/files/public/components/image/image.stories.tsx index 1b30d8fb8fca6..15967437d0849 100644 --- a/src/plugins/files/public/components/image/image.stories.tsx +++ b/src/plugins/files/public/components/image/image.stories.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; -import { css } from '@emotion/react'; import { getImageMetadata } from '../util'; import { Image, Props } from './image'; @@ -82,25 +81,3 @@ WithCustomSizing.loaders = [ WithCustomSizing.args = { css: `width: 100px; height: 500px; object-fit: fill`, }; - -export const OffScreen = Template.bind({}); -OffScreen.storyName = 'Offscreen'; -OffScreen.args = { - /* demonstrates how to make image lazy load */ - /* @note: the demo doesn't really work with the base64 encoded src */ - loading: 'lazy', -}; -OffScreen.decorators = [ - (Story) => ( - <> -

Scroll down

-
- -
- - ), -]; diff --git a/src/plugins/files/public/components/image/image.tsx b/src/plugins/files/public/components/image/image.tsx index e3b2b1396aec5..dc0ffe6cf0e51 100644 --- a/src/plugins/files/public/components/image/image.tsx +++ b/src/plugins/files/public/components/image/image.tsx @@ -25,25 +25,39 @@ export type Props = { meta?: FileImageMetadata } & EuiImageProps; */ export const Image = ({ src, url, alt, onLoad, onError, meta, ...rest }: Props) => { const imageSrc = (src || url)!; // allows to use either `src` or `url` - const [currentImageSrc, onBlurHashLoaded] = useCurrentImageSrc(imageSrc, meta); + + 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] + ); + + // prettier-ignore + const currentSrc = (isBlurHashLoaded || !blurhashSrc) ? imageSrc : blurhashSrc return ( { - if (currentImageSrc === imageSrc) { + // 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(() => { - onBlurHashLoaded(); + setIsBlurHashLoaded(true); }, 3000); } else { - onBlurHashLoaded(); + setIsBlurHashLoaded(true); } } }} @@ -53,35 +67,3 @@ export const Image = ({ src, url, alt, onLoad, onError, meta, ...rest }: Props) /> ); }; - -function useCurrentImageSrc( - imageSrc: string, - meta: FileImageMetadata | undefined -): [string, () => void] { - const [isBlurHashLoaded, setIsBlurHashLoaded] = useState(false); - const [blurhashSrc, setBlurHash] = useState(null); - const { blurhash, width, height } = meta ?? {}; - const hasBlurHash = blurhash && width && height; - React.useEffect(() => { - if (blurhash && width && height) { - getBlurhashSrc({ - height, - width, - hash: blurhash, - }).then((hash) => { - setBlurHash(hash); - }); - } else { - setBlurHash(null); - } - }, [blurhash, width, height]); - - const currentSrc = isBlurHashLoaded || !hasBlurHash ? imageSrc : blurhashSrc ? blurhashSrc : null; - - return [ - currentSrc as string, - () => { - setIsBlurHashLoaded(true); - }, - ]; -} diff --git a/src/plugins/files/public/components/util/image_metadata.ts b/src/plugins/files/public/components/util/image_metadata.ts index e640ccd38cb1f..b1faed7b294b8 100644 --- a/src/plugins/files/public/components/util/image_metadata.ts +++ b/src/plugins/files/public/components/util/image_metadata.ts @@ -57,6 +57,8 @@ export async function getImageMetadata(file: File | Blob): Promise { - const canvas = document.createElement('canvas'); +}): string { + const smallSizeImageCanvas = document.createElement('canvas'); const { width: blurWidth, height: blurHeight } = fitToBox(width, height); - canvas.width = blurWidth; - canvas.height = blurHeight; + smallSizeImageCanvas.width = blurWidth; + smallSizeImageCanvas.height = blurHeight; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(blurWidth, blurHeight); + const smallSizeImageCtx = smallSizeImageCanvas.getContext('2d')!; + const imageData = smallSizeImageCtx.createImageData(blurWidth, blurHeight); imageData.data.set(bh.decode(hash, blurWidth, blurHeight)); - ctx.putImageData(imageData, 0, 0); + smallSizeImageCtx.putImageData(imageData, 0, 0); - return new Promise((resolve) => { - // On this point canvas contains a downsized blurred image - // Now we have to stretch it to the size of the original image - const image = new Image(); - image.src = canvas.toDataURL(); - image.onload = () => { - canvas.width = width; - canvas.height = height; - ctx.clearRect(0, 0, width, height); - ctx.drawImage(image, 0, 0, width, height); - resolve(canvas.toDataURL()); - }; - }); + // scale back the blurred image to the size of the original image, + // so it is sized and positioned the same as the original image when used with an `` 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(); }