From 15a598233f27a0f6c56b32e4717f651a1a630af9 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 6 Oct 2022 09:05:34 -0600 Subject: [PATCH] [Files] Use blurhash for images (#142493) (#142858) * added blurhash dep * send blurhash from client * added blurhash to server side * added blurhash to headers * added hash to files headers part ii * move custom header name to shared * added server side test to make sure blurhash is being stored with the file * move blurhash logic to common components logic * wip: moving a lot of stuff around and breaking up image component to parts * added logic for loading blurhash client-side using header values * reorder some stuff, added http to files context for example * added resize files test * tweak sizes of the blurs * removed custom blurhash header * renamed util to blurhash * fixed some loading states to not show alt text, updated stories to show blurhash and removed styling on container * remove http from filescontext * pass blurhash to image component * improved usability of the component by passing in wrapper props and allowing consumers to set an image size the same way they can for EuiImage * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * removed all traces of blurhash field from file saved object * create special file image metadata type * rename blurhash files and return full image metadata * refactor blurhash in upload state to image metadata factory * finish refactor of blurhash file * pass back the original image size to the metadata * more refactoring and added some comments to the metadata type * pass metadata type through to endpoint * pass metadata type through on client side * wip * updated files example to pass through shape of metadata * some final touches * updated comment * make default size original * rename common -> util * update import path after refactor * update style overrides for the blurhash story * MyImage -> Img * fix type lints Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> (cherry picked from commit dbbf3ad42b5aab62bb106a78d41ef38438f6dd66) Co-authored-by: Jean-Louis Leysens --- package.json | 1 + x-pack/examples/files_example/common/index.ts | 4 +- .../files_example/public/components/app.tsx | 9 +- .../public/components/details_flyout.tsx | 29 ++++-- .../files_example/public/components/modal.tsx | 4 +- .../examples/files_example/public/plugin.ts | 6 +- x-pack/examples/files_example/public/types.ts | 5 +- x-pack/plugins/files/common/index.ts | 1 + x-pack/plugins/files/common/types.ts | 19 ++++ .../files/public/components/context.tsx | 1 - .../components/image/components/blurhash.tsx | 62 +++++++++++++ .../components/image/components/img.tsx | 50 ++++++++++ .../components/image/components/index.ts | 10 ++ .../image/image.constants.stories.tsx | 7 ++ .../public/components/image/image.stories.tsx | 64 ++++++++++--- .../files/public/components/image/image.tsx | 92 +++++++++++++++---- .../files/public/components/image/styles.ts | 30 ++++++ .../components/upload_file/upload_file.tsx | 2 +- .../upload_file/upload_state.test.ts | 9 +- .../components/upload_file/upload_state.ts | 28 +++--- .../components/util/image_metadata.test.ts | 55 +++++++++++ .../public/components/util/image_metadata.ts | 79 ++++++++++++++++ .../files/public/components/util/index.ts | 9 ++ x-pack/plugins/files/public/plugin.ts | 11 ++- x-pack/plugins/files/public/types.ts | 24 ++--- .../files/server/routes/common.test.ts | 10 +- x-pack/plugins/files/server/routes/common.ts | 7 +- .../files/server/routes/file_kind/create.ts | 2 +- .../files/server/routes/file_kind/download.ts | 2 +- .../server/routes/file_kind/get_by_id.ts | 2 +- .../files/server/routes/file_kind/list.ts | 2 +- .../files/server/routes/file_kind/update.ts | 2 +- .../server/routes/public_facing/download.ts | 2 +- yarn.lock | 5 + 34 files changed, 551 insertions(+), 94 deletions(-) create mode 100644 x-pack/plugins/files/public/components/image/components/blurhash.tsx create mode 100644 x-pack/plugins/files/public/components/image/components/img.tsx create mode 100644 x-pack/plugins/files/public/components/image/components/index.ts create mode 100644 x-pack/plugins/files/public/components/image/styles.ts create mode 100644 x-pack/plugins/files/public/components/util/image_metadata.test.ts create mode 100644 x-pack/plugins/files/public/components/util/image_metadata.ts create mode 100644 x-pack/plugins/files/public/components/util/index.ts diff --git a/package.json b/package.json index 76f19e44e157f..b811b962a0abf 100644 --- a/package.json +++ b/package.json @@ -428,6 +428,7 @@ "axios": "^0.27.2", "base64-js": "^1.3.1", "bitmap-sdf": "^1.0.3", + "blurhash": "^2.0.1", "brace": "0.11.1", "byte-size": "^8.1.0", "canvg": "^3.0.9", diff --git a/x-pack/examples/files_example/common/index.ts b/x-pack/examples/files_example/common/index.ts index b9b30fac5cb50..1586d92c4c05a 100644 --- a/x-pack/examples/files_example/common/index.ts +++ b/x-pack/examples/files_example/common/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FileKind } from '@kbn/files-plugin/common'; +import type { FileKind, FileImageMetadata } from '@kbn/files-plugin/common'; export const PLUGIN_ID = 'filesExample'; export const PLUGIN_NAME = 'filesExample'; @@ -27,3 +27,5 @@ export const exampleFileKind: FileKind = { update: httpTags, }, }; + +export type MyImageMetadata = FileImageMetadata; diff --git a/x-pack/examples/files_example/public/components/app.tsx b/x-pack/examples/files_example/public/components/app.tsx index 124bc842af5ce..cf0f4461b8b62 100644 --- a/x-pack/examples/files_example/public/components/app.tsx +++ b/x-pack/examples/files_example/public/components/app.tsx @@ -21,8 +21,9 @@ import { } from '@elastic/eui'; import { CoreStart } from '@kbn/core/public'; -import { DetailsFlyout } from './details_flyout'; +import type { MyImageMetadata } from '../../common'; import type { FileClients } from '../types'; +import { DetailsFlyout } from './details_flyout'; import { ConfirmButtonIcon } from './confirm_button'; import { Modal } from './modal'; @@ -31,7 +32,7 @@ interface FilesExampleAppDeps { notifications: CoreStart['notifications']; } -type ListResponse = FilesClientResponses['list']; +type ListResponse = FilesClientResponses['list']; export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) => { const { data, isLoading, error, refetch } = useQuery(['files'], () => @@ -39,7 +40,7 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = ); const [showUploadModal, setShowUploadModal] = useState(false); const [isDeletingFile, setIsDeletingFile] = useState(false); - const [selectedItem, setSelectedItem] = useState(); + const [selectedItem, setSelectedItem] = useState>(); const renderToolsRight = () => { return [ @@ -55,7 +56,7 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = const items = [...(data?.files ?? [])].reverse(); - const columns: EuiInMemoryTableProps['columns'] = [ + const columns: EuiInMemoryTableProps>['columns'] = [ { field: 'name', name: 'Name', diff --git a/x-pack/examples/files_example/public/components/details_flyout.tsx b/x-pack/examples/files_example/public/components/details_flyout.tsx index 48eaa9a6ab8e4..a417752d1a666 100644 --- a/x-pack/examples/files_example/public/components/details_flyout.tsx +++ b/x-pack/examples/files_example/public/components/details_flyout.tsx @@ -7,7 +7,6 @@ import moment from 'moment'; import type { FunctionComponent } from 'react'; import React from 'react'; -import { css } from '@emotion/react'; import { EuiFlyout, EuiFlyoutHeader, @@ -22,11 +21,13 @@ import { EuiSpacer, } from '@elastic/eui'; import type { FileJSON } from '@kbn/files-plugin/common'; +import { css } from '@emotion/react'; +import type { MyImageMetadata } from '../../common'; import { FileClients } from '../types'; import { Image } from '../imports'; interface Props { - file: FileJSON; + file: FileJSON; files: FileClients; onDismiss: () => void; } @@ -40,8 +41,24 @@ export const DetailsFlyout: FunctionComponent = ({ files, file, onDismiss +
+ {file.alt +
+ = ({ files, file, onDismiss }, ]} /> - - {file.alt
diff --git a/x-pack/examples/files_example/public/components/modal.tsx b/x-pack/examples/files_example/public/components/modal.tsx index a4b54b694713f..9d323b240f416 100644 --- a/x-pack/examples/files_example/public/components/modal.tsx +++ b/x-pack/examples/files_example/public/components/modal.tsx @@ -8,11 +8,11 @@ import type { FunctionComponent } from 'react'; import React from 'react'; import { EuiModal, EuiModalHeader, EuiModalBody, EuiText } from '@elastic/eui'; -import { exampleFileKind } from '../../common'; +import { exampleFileKind, MyImageMetadata } from '../../common'; import { FilesClient, UploadFile } from '../imports'; interface Props { - client: FilesClient; + client: FilesClient; onDismiss: () => void; onUploaded: () => void; } diff --git a/x-pack/examples/files_example/public/plugin.ts b/x-pack/examples/files_example/public/plugin.ts index 5dd961278dbd0..98a6b6f6e4608 100644 --- a/x-pack/examples/files_example/public/plugin.ts +++ b/x-pack/examples/files_example/public/plugin.ts @@ -6,7 +6,7 @@ */ import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { PLUGIN_ID, PLUGIN_NAME, exampleFileKind } from '../common'; +import { PLUGIN_ID, PLUGIN_NAME, exampleFileKind, MyImageMetadata } from '../common'; import { FilesExamplePluginsStart, FilesExamplePluginsSetup } from './types'; export class FilesExamplePlugin @@ -28,8 +28,8 @@ export class FilesExamplePlugin coreStart, { files: { - unscoped: deps.files.filesClientFactory.asUnscoped(), - example: deps.files.filesClientFactory.asScoped(exampleFileKind.id), + unscoped: deps.files.filesClientFactory.asUnscoped(), + example: deps.files.filesClientFactory.asScoped(exampleFileKind.id), }, }, params diff --git a/x-pack/examples/files_example/public/types.ts b/x-pack/examples/files_example/public/types.ts index b1e19b13d1b9b..fbc058d9aec30 100644 --- a/x-pack/examples/files_example/public/types.ts +++ b/x-pack/examples/files_example/public/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { MyImageMetadata } from '../common'; import type { FilesSetup, FilesStart, ScopedFilesClient, FilesClient } from './imports'; export interface FilesExamplePluginsSetup { @@ -16,9 +17,9 @@ export interface FilesExamplePluginsStart { } export interface FileClients { - unscoped: FilesClient; + unscoped: FilesClient; // Example file kind - example: ScopedFilesClient; + example: ScopedFilesClient; } export interface AppPluginStartDependencies { diff --git a/x-pack/plugins/files/common/index.ts b/x-pack/plugins/files/common/index.ts index 8a97030efff6f..be06c5708ce06 100755 --- a/x-pack/plugins/files/common/index.ts +++ b/x-pack/plugins/files/common/index.ts @@ -21,6 +21,7 @@ export type { FileSavedObject, BaseFileMetadata, FileShareOptions, + FileImageMetadata, FileUnshareOptions, BlobStorageSettings, UpdatableFileMetadata, diff --git a/x-pack/plugins/files/common/types.ts b/x-pack/plugins/files/common/types.ts index fd1948e6333ea..53f97962597a8 100644 --- a/x-pack/plugins/files/common/types.ts +++ b/x-pack/plugins/files/common/types.ts @@ -536,3 +536,22 @@ export interface FilesMetrics { */ countByExtension: Record; } + +/** + * Set of metadata captured for every image uploaded via the file services' + * public components. + */ +export interface FileImageMetadata { + /** + * The blurhash that can be displayed while the image is loading + */ + blurhash?: string; + /** + * Width, in px, of the original image + */ + width: number; + /** + * Height, in px, of the original image + */ + height: number; +} diff --git a/x-pack/plugins/files/public/components/context.tsx b/x-pack/plugins/files/public/components/context.tsx index ceed14b52abbd..e55c0c45e4da6 100644 --- a/x-pack/plugins/files/public/components/context.tsx +++ b/x-pack/plugins/files/public/components/context.tsx @@ -21,7 +21,6 @@ export const useFilesContext = () => { } return ctx; }; - export const FilesContext: FunctionComponent = ({ children }) => { return ( = ({ + 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/x-pack/plugins/files/public/components/image/components/img.tsx b/x-pack/plugins/files/public/components/image/components/img.tsx new file mode 100644 index 0000000000000..295b062ca1fd8 --- /dev/null +++ b/x-pack/plugins/files/public/components/image/components/img.tsx @@ -0,0 +1,50 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +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 { + hidden: boolean; + size?: EuiImageSize; + observerRef: (el: null | HTMLImageElement) => void; +} + +export const Img = React.forwardRef( + ({ observerRef, src, hidden, size, ...rest }, ref) => { + const { euiTheme } = useEuiTheme(); + const styles = [ + css` + transition: opacity ${euiTheme.animation.extraFast}; + `, + hidden + ? css` + visibility: hidden; + ` + : 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/x-pack/plugins/files/public/components/image/components/index.ts b/x-pack/plugins/files/public/components/image/components/index.ts new file mode 100644 index 0000000000000..7fee8f7fd63fb --- /dev/null +++ b/x-pack/plugins/files/public/components/image/components/index.ts @@ -0,0 +1,10 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Img } from './img'; +export type { Props as ImgProps } from './img'; +export { Blurhash } from './blurhash'; diff --git a/x-pack/plugins/files/public/components/image/image.constants.stories.tsx b/x-pack/plugins/files/public/components/image/image.constants.stories.tsx index 9b4c2d3b64aff..cc5ff23cd2810 100644 --- a/x-pack/plugins/files/public/components/image/image.constants.stories.tsx +++ b/x-pack/plugins/files/public/components/image/image.constants.stories.tsx @@ -5,4 +5,11 @@ * 2.0. */ +export function getImageData(): Blob { + const byteChars = window.atob(base64dLogo); + const charpoints = new Array(byteChars.length); + for (let x = 0; x < byteChars.length; x++) charpoints[x] = byteChars.charCodeAt(x); + return new Blob([new Uint8Array(charpoints)], { type: 'image/png' }); +} + export const base64dLogo = `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`; diff --git a/x-pack/plugins/files/public/components/image/image.stories.tsx b/x-pack/plugins/files/public/components/image/image.stories.tsx index 400b7eb963207..02daf7badb329 100644 --- a/x-pack/plugins/files/public/components/image/image.stories.tsx +++ b/x-pack/plugins/files/public/components/image/image.stories.tsx @@ -5,38 +5,78 @@ * 2.0. */ import React from 'react'; -import { ComponentStory } from '@storybook/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 { base64dLogo } from './image.constants.stories'; +import { getImageData as getBlob, base64dLogo } from './image.constants.stories'; -const defaultArgs = { alt: 'my alt text', src: `data:image/png;base64,${base64dLogo}` }; +const defaultArgs: Props = { alt: 'test', src: `data:image/png;base64,${base64dLogo}` }; export default { title: 'components/Image', component: Image, args: defaultArgs, -}; - -const baseStyle = css` - width: 400px; -`; + decorators: [ + (Story) => ( + + + + ), + ], +} as ComponentMeta; -const Template: ComponentStory = (props: Props) => ( - +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'; BrokenSrc.args = { - src: 'broken', + src: 'foo', }; +export const WithBlurhashAndBrokenSrc = Template.bind({}); +WithBlurhashAndBrokenSrc.storyName = 'With blurhash and broken src'; +WithBlurhashAndBrokenSrc.args = { + src: 'foo', +}; +WithBlurhashAndBrokenSrc.loaders = [ + async () => ({ + blurhash: await getImageMetadata(getBlob()), + }), +]; + export const OffScreen = Template.bind({}); -OffScreen.args = { ...defaultArgs, onFirstVisible: action('visible') }; +OffScreen.storyName = 'Offscreen'; +OffScreen.args = { onFirstVisible: action('visible') }; OffScreen.decorators = [ (Story) => ( <> diff --git a/x-pack/plugins/files/public/components/image/image.tsx b/x-pack/plugins/files/public/components/image/image.tsx index 96ac1a2eee78c..915f45c828f66 100644 --- a/x-pack/plugins/files/public/components/image/image.tsx +++ b/x-pack/plugins/files/public/components/image/image.tsx @@ -4,13 +4,30 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { MutableRefObject } from 'react'; -import type { ImgHTMLAttributes } from 'react'; +import React, { HTMLAttributes } from 'react'; +import { type ImgHTMLAttributes, useState, useEffect } from 'react'; +import { css } from '@emotion/react'; +import type { FileImageMetadata } from '../../../common'; import { useViewportObserver } from './use_viewport_observer'; +import { Img, type ImgProps, Blurhash } from './components'; +import { sizes } from './styles'; 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 */ @@ -28,22 +45,65 @@ export interface Props extends ImgHTMLAttributes { * ``` */ export const Image = React.forwardRef( - ({ src, alt, onFirstVisible, ...rest }, ref) => { + ( + { 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 }); + + useEffect(() => { + let unmounted = false; + const id = window.setTimeout(() => { + if (!unmounted) setBlurDelayExpired(true); + }, 200); + return () => { + unmounted = true; + window.clearTimeout(id); + }; + }, []); + + const knownSize = size ? sizes[size] : undefined; + return ( - { - observerRef(element); - if (ref) { - if (typeof ref === 'function') ref(element); - else (ref as MutableRefObject).current = element; - } - }} - // TODO: We should have a lower resolution alternative to display - src={isVisible ? src : undefined} - alt={alt} - /> +
+ {blurDelayExpired && meta?.blurhash && ( + + )} + { + setIsLoaded(true); + onLoad?.(ev); + }} + onError={(ev) => { + setIsLoaded(true); + onError?.(ev); + }} + {...rest} + /> +
); } ); diff --git a/x-pack/plugins/files/public/components/image/styles.ts b/x-pack/plugins/files/public/components/image/styles.ts new file mode 100644 index 0000000000000..b14121c667a50 --- /dev/null +++ b/x-pack/plugins/files/public/components/image/styles.ts @@ -0,0 +1,30 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +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/x-pack/plugins/files/public/components/upload_file/upload_file.tsx b/x-pack/plugins/files/public/components/upload_file/upload_file.tsx index 8e0d8ed59392b..e85460ca7c1e3 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_file.tsx +++ b/x-pack/plugins/files/public/components/upload_file/upload_file.tsx @@ -40,7 +40,7 @@ export interface Props { /** * A files client that will be used process uploads. */ - client: FilesClient; + client: FilesClient; /** * Allow users to clear a file after uploading. * diff --git a/x-pack/plugins/files/public/components/upload_file/upload_state.test.ts b/x-pack/plugins/files/public/components/upload_file/upload_state.test.ts index 8fc0c02e0f60e..3a4e19adf8114 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_state.test.ts +++ b/x-pack/plugins/files/public/components/upload_file/upload_state.test.ts @@ -11,6 +11,7 @@ import { TestScheduler } from 'rxjs/testing'; import type { FileKind, FileJSON } from '../../../common'; import { createMockFilesClient } from '../../mocks'; import type { FilesClient } from '../../types'; +import { ImageMetadataFactory } from '../util/image_metadata'; import { UploadState } from './upload_state'; @@ -21,6 +22,7 @@ describe('UploadState', () => { let filesClient: DeeplyMockedKeys; let uploadState: UploadState; let testScheduler: TestScheduler; + const imageMetadataFactory = (() => of(undefined)) as unknown as ImageMetadataFactory; beforeEach(() => { filesClient = createMockFilesClient(); @@ -28,7 +30,9 @@ describe('UploadState', () => { filesClient.upload.mockReturnValue(of(undefined) as any); uploadState = new UploadState( { id: 'test', http: {}, maxSizeBytes: 1000 } as FileKind, - filesClient + filesClient, + {}, + imageMetadataFactory ); testScheduler = getTestScheduler(); }); @@ -189,7 +193,8 @@ describe('UploadState', () => { uploadState = new UploadState( { id: 'test', http: {}, maxSizeBytes: 1000 } as FileKind, filesClient, - { allowRepeatedUploads: true } + { allowRepeatedUploads: true }, + imageMetadataFactory ); const file1 = { name: 'test' } as File; const file2 = { name: 'test 2.png' } as File; diff --git a/x-pack/plugins/files/public/components/upload_file/upload_state.ts b/x-pack/plugins/files/public/components/upload_file/upload_state.ts index d5fbc04512fdc..dd03eb7aee56a 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_state.ts +++ b/x-pack/plugins/files/public/components/upload_file/upload_state.ts @@ -29,6 +29,7 @@ import { } from 'rxjs'; import type { FileKind, FileJSON } from '../../../common/types'; import type { FilesClient } from '../../types'; +import { ImageMetadataFactory, getImageMetadata, isImage } from '../util'; import { i18nTexts } from './i18n_texts'; import { createStateSubject, type SimpleStateSubject, parseFileName } from './util'; @@ -68,7 +69,8 @@ export class UploadState { constructor( private readonly fileKind: FileKind, private readonly client: FilesClient, - private readonly opts: UploadOptions = { allowRepeatedUploads: false } + private readonly opts: UploadOptions = { allowRepeatedUploads: false }, + private readonly loadImageMetadata: ImageMetadataFactory = getImageMetadata ) { const latestFiles$ = this.files$$.pipe(switchMap((files$) => combineLatest(files$))); this.subscriptions = [ @@ -171,15 +173,17 @@ export class UploadState { const { name } = parseFileName(file.name); const mime = file.type || undefined; - - return from( - this.client.create({ - kind: this.fileKind.id, - name, - mimeType: mime, - meta: meta as Record, - }) - ).pipe( + const _meta = meta as Record; + + return from(isImage(file) ? this.loadImageMetadata(file) : of(undefined)).pipe( + mergeMap((imageMetadata) => + this.client.create({ + kind: this.fileKind.id, + name, + mimeType: mime, + meta: imageMetadata ? { ...imageMetadata, ..._meta } : _meta, + }) + ), mergeMap((result) => { uploadTarget = result.file; return race( @@ -240,10 +244,12 @@ export class UploadState { export const createUploadState = ({ fileKind, client, + imageMetadataFactory, ...options }: { fileKind: FileKind; client: FilesClient; + imageMetadataFactory?: ImageMetadataFactory; } & UploadOptions) => { - return new UploadState(fileKind, client, options); + return new UploadState(fileKind, client, options, imageMetadataFactory); }; diff --git a/x-pack/plugins/files/public/components/util/image_metadata.test.ts b/x-pack/plugins/files/public/components/util/image_metadata.test.ts new file mode 100644 index 0000000000000..16980aee00296 --- /dev/null +++ b/x-pack/plugins/files/public/components/util/image_metadata.test.ts @@ -0,0 +1,55 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { fitToBox } from './image_metadata'; +describe('util', () => { + describe('fitToBox', () => { + test('300x300', () => { + expect(fitToBox(300, 300)).toMatchInlineSnapshot(` + Object { + "height": 300, + "width": 300, + } + `); + }); + + test('300x150', () => { + expect(fitToBox(300, 150)).toMatchInlineSnapshot(` + Object { + "height": 150, + "width": 300, + } + `); + }); + + test('4500x9000', () => { + expect(fitToBox(4500, 9000)).toMatchInlineSnapshot(` + Object { + "height": 300, + "width": 150, + } + `); + }); + + test('1000x300', () => { + expect(fitToBox(1000, 300)).toMatchInlineSnapshot(` + Object { + "height": 90, + "width": 300, + } + `); + }); + + test('0x0', () => { + expect(fitToBox(0, 0)).toMatchInlineSnapshot(` + Object { + "height": 0, + "width": 0, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/files/public/components/util/image_metadata.ts b/x-pack/plugins/files/public/components/util/image_metadata.ts new file mode 100644 index 0000000000000..9358dda9d05ad --- /dev/null +++ b/x-pack/plugins/files/public/components/util/image_metadata.ts @@ -0,0 +1,79 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as bh from 'blurhash'; +import type { FileImageMetadata } from '../../../common'; + +export function isImage(file: Blob | File): boolean { + return file.type?.startsWith('image/'); +} + +export const boxDimensions = { + width: 300, + height: 300, +}; + +/** + * Calculate the size of an image, fitting to our limits see {@link boxDimensions}, + * while preserving the aspect ratio. + */ +export function fitToBox(width: number, height: number): { width: number; height: number } { + const offsetRatio = Math.abs( + Math.min( + // Find the aspect at which our box is smallest, if less than 1, it means we exceed the limits + Math.min(boxDimensions.width / width, boxDimensions.height / height), + // Values greater than 1 are within our limits + 1 + ) - 1 // Get the percentage we are exceeding. E.g., 0.3 - 1 = -0.7 means the image needs to shrink by 70% to fit + ); + return { + width: Math.floor(width - offsetRatio * width), + height: Math.floor(height - offsetRatio * height), + }; +} + +/** + * Get the native size of the image + */ +function loadImage(src: string): Promise { + return new Promise((res, rej) => { + const image = new window.Image(); + image.src = src; + image.onload = () => res(image); + image.onerror = rej; + }); +} + +/** + * Extract image metadata, assumes that file or blob as an image! + */ +export async function getImageMetadata(file: File | Blob): Promise { + const imgUrl = window.URL.createObjectURL(file); + try { + const image = await loadImage(imgUrl); + const canvas = document.createElement('canvas'); + const { width, height } = fitToBox(image.width, image.height); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get 2d canvas context!'); + ctx.drawImage(image, 0, 0, width, height); + const imgData = ctx.getImageData(0, 0, width, height); + return { + blurhash: bh.encode(imgData.data, imgData.width, imgData.height, 4, 4), + width: image.width, + height: image.height, + }; + } catch (e) { + // Don't error out if we cannot generate the blurhash + return undefined; + } finally { + window.URL.revokeObjectURL(imgUrl); + } +} + +export type ImageMetadataFactory = typeof getImageMetadata; diff --git a/x-pack/plugins/files/public/components/util/index.ts b/x-pack/plugins/files/public/components/util/index.ts new file mode 100644 index 0000000000000..e3e30fdb17bb3 --- /dev/null +++ b/x-pack/plugins/files/public/components/util/index.ts @@ -0,0 +1,9 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getImageMetadata, isImage, fitToBox } from './image_metadata'; +export type { ImageMetadataFactory } from './image_metadata'; diff --git a/x-pack/plugins/files/public/plugin.ts b/x-pack/plugins/files/public/plugin.ts index 8ebbd71cdbe1f..2f5481f8f2511 100644 --- a/x-pack/plugins/files/public/plugin.ts +++ b/x-pack/plugins/files/public/plugin.ts @@ -11,9 +11,10 @@ import { setFileKindsRegistry, FileKindsRegistryImpl, } from '../common/file_kinds_registry'; -import type { FilesClientFactory } from './types'; +import type { FilesClient, FilesClientFactory } from './types'; import { createFilesClient } from './files_client'; import { FileKind } from '../common'; +import { ScopedFilesClient } from '.'; /** * Public setup-phase contract @@ -50,11 +51,11 @@ export class FilesPlugin implements Plugin { setup(core: CoreSetup): FilesSetup { this.filesClientFactory = { - asScoped(fileKind: string) { - return createFilesClient({ fileKind, http: core.http }); + asScoped(fileKind: string) { + return createFilesClient({ fileKind, http: core.http }) as ScopedFilesClient; }, - asUnscoped() { - return createFilesClient({ http: core.http }); + asUnscoped() { + return createFilesClient({ http: core.http }) as FilesClient; }, }; return { diff --git a/x-pack/plugins/files/public/types.ts b/x-pack/plugins/files/public/types.ts index 25aab6e787b6b..1cc69ac4ed23e 100644 --- a/x-pack/plugins/files/public/types.ts +++ b/x-pack/plugins/files/public/types.ts @@ -60,13 +60,13 @@ interface GlobalEndpoints { /** * A client that can be used to manage a specific {@link FileKind}. */ -export interface FilesClient extends GlobalEndpoints { +export interface FilesClient extends GlobalEndpoints { /** * Create a new file object with the provided metadata. * * @param args - create file args */ - create: ClientMethodFrom; + create: ClientMethodFrom>; /** * Delete a file object and all associated share and content objects. * @@ -78,19 +78,19 @@ export interface FilesClient extends GlobalEndpoints { * * @param args - get file by ID args */ - getById: ClientMethodFrom; + getById: ClientMethodFrom>; /** * List all file objects, of a given {@link FileKind}. * * @param args - list files args */ - list: ClientMethodFrom; + list: ClientMethodFrom>; /** * Update a set of of metadata values of the file object. * * @param args - update file args */ - update: ClientMethodFrom; + update: ClientMethodFrom>; /** * Stream the contents of the file to Kibana server for storage. * @@ -151,8 +151,8 @@ export interface FilesClient extends GlobalEndpoints { listShares: ClientMethodFrom; } -export type FilesClientResponses = { - [K in keyof FilesClient]: Awaited>; +export type FilesClientResponses = { + [K in keyof FilesClient]: Awaited[K]>>; }; /** @@ -161,10 +161,10 @@ export type FilesClientResponses = { * More convenient if you want to re-use the same client for the same file kind * and not specify the kind every time. */ -export type ScopedFilesClient = { +export type ScopedFilesClient = { [K in keyof FilesClient]: K extends 'list' - ? (arg?: Omit[0], 'kind'>) => ReturnType - : (arg: Omit[0], 'kind'>) => ReturnType; + ? (arg?: Omit[K]>[0], 'kind'>) => ReturnType[K]> + : (arg: Omit[K]>[0], 'kind'>) => ReturnType[K]>; }; /** @@ -174,11 +174,11 @@ export interface FilesClientFactory { /** * Create a files client. */ - asUnscoped(): FilesClient; + asUnscoped(): FilesClient; /** * Create a {@link ScopedFileClient} for a given {@link FileKind}. * * @param fileKind - The {@link FileKind} to create a client for. */ - asScoped(fileKind: string): ScopedFilesClient; + asScoped(fileKind: string): ScopedFilesClient; } diff --git a/x-pack/plugins/files/server/routes/common.test.ts b/x-pack/plugins/files/server/routes/common.test.ts index 2c4d302d04625..1f9292e3ff07a 100644 --- a/x-pack/plugins/files/server/routes/common.test.ts +++ b/x-pack/plugins/files/server/routes/common.test.ts @@ -26,30 +26,30 @@ describe('getDownloadHeadersForFile', () => { const file = { data: { name: 'test', mimeType: undefined } } as unknown as File; test('no mime type and name from file object', () => { - expect(getDownloadHeadersForFile(file, undefined)).toEqual( + expect(getDownloadHeadersForFile({ file, fileName: undefined })).toEqual( expectHeaders({ contentType: 'application/octet-stream', contentDisposition: 'test' }) ); }); test('no mime type and name (without ext)', () => { - expect(getDownloadHeadersForFile(file, 'myfile')).toEqual( + expect(getDownloadHeadersForFile({ file, fileName: 'myfile' })).toEqual( expectHeaders({ contentType: 'application/octet-stream', contentDisposition: 'myfile' }) ); }); test('no mime type and name (with ext)', () => { - expect(getDownloadHeadersForFile(file, 'myfile.png')).toEqual( + expect(getDownloadHeadersForFile({ file, fileName: 'myfile.png' })).toEqual( expectHeaders({ contentType: 'image/png', contentDisposition: 'myfile.png' }) ); }); test('mime type and no name', () => { const fileWithMime = { data: { ...file.data, mimeType: 'application/pdf' } } as File; - expect(getDownloadHeadersForFile(fileWithMime, undefined)).toEqual( + expect(getDownloadHeadersForFile({ file: fileWithMime, fileName: undefined })).toEqual( expectHeaders({ contentType: 'application/pdf', contentDisposition: 'test' }) ); }); test('mime type and name', () => { const fileWithMime = { data: { ...file.data, mimeType: 'application/pdf' } } as File; - expect(getDownloadHeadersForFile(fileWithMime, 'a cool file.pdf')).toEqual( + expect(getDownloadHeadersForFile({ file: fileWithMime, fileName: 'a cool file.pdf' })).toEqual( expectHeaders({ contentType: 'application/pdf', contentDisposition: 'a cool file.pdf' }) ); }); diff --git a/x-pack/plugins/files/server/routes/common.ts b/x-pack/plugins/files/server/routes/common.ts index 0730a6435de02..8e17a39511b53 100644 --- a/x-pack/plugins/files/server/routes/common.ts +++ b/x-pack/plugins/files/server/routes/common.ts @@ -8,7 +8,12 @@ import mime from 'mime'; import type { ResponseHeaders } from '@kbn/core/server'; import type { File } from '../../common/types'; -export function getDownloadHeadersForFile(file: File, fileName?: string): ResponseHeaders { +interface Args { + file: File; + fileName?: string; +} + +export function getDownloadHeadersForFile({ file, fileName }: Args): ResponseHeaders { return { 'content-type': (fileName && mime.getType(fileName)) ?? file.data.mimeType ?? 'application/octet-stream', diff --git a/x-pack/plugins/files/server/routes/file_kind/create.ts b/x-pack/plugins/files/server/routes/file_kind/create.ts index a134bdd292e98..78a7260771a16 100644 --- a/x-pack/plugins/files/server/routes/file_kind/create.ts +++ b/x-pack/plugins/files/server/routes/file_kind/create.ts @@ -23,7 +23,7 @@ const rt = { }), }; -export type Endpoint = CreateRouteDefinition; +export type Endpoint = CreateRouteDefinition }>; export const handler: CreateHandler = async ({ fileKind, files }, req, res) => { const { fileService } = await files; diff --git a/x-pack/plugins/files/server/routes/file_kind/download.ts b/x-pack/plugins/files/server/routes/file_kind/download.ts index 85f8b5bd0a2d6..d4ae37ddb6623 100644 --- a/x-pack/plugins/files/server/routes/file_kind/download.ts +++ b/x-pack/plugins/files/server/routes/file_kind/download.ts @@ -40,7 +40,7 @@ export const handler: CreateHandler = async ({ files, fileKind }, req, const body: Response = await file.downloadContent(); return res.ok({ body, - headers: getDownloadHeadersForFile(file, fileName), + headers: getDownloadHeadersForFile({ file, fileName }), }); } catch (e) { if (e instanceof fileErrors.NoDownloadAvailableError) { diff --git a/x-pack/plugins/files/server/routes/file_kind/get_by_id.ts b/x-pack/plugins/files/server/routes/file_kind/get_by_id.ts index 00c5bd2312f89..4d86e05564fdf 100644 --- a/x-pack/plugins/files/server/routes/file_kind/get_by_id.ts +++ b/x-pack/plugins/files/server/routes/file_kind/get_by_id.ts @@ -19,7 +19,7 @@ const rt = { }), }; -export type Endpoint = CreateRouteDefinition; +export type Endpoint = CreateRouteDefinition }>; export const handler: CreateHandler = async ({ files, fileKind }, req, res) => { const { fileService } = await files; diff --git a/x-pack/plugins/files/server/routes/file_kind/list.ts b/x-pack/plugins/files/server/routes/file_kind/list.ts index 3f1e36913bdc9..b6a869117b37f 100644 --- a/x-pack/plugins/files/server/routes/file_kind/list.ts +++ b/x-pack/plugins/files/server/routes/file_kind/list.ts @@ -18,7 +18,7 @@ const rt = { }), }; -export type Endpoint = CreateRouteDefinition; +export type Endpoint = CreateRouteDefinition> }>; export const handler: CreateHandler = async ({ files, fileKind }, req, res) => { const { diff --git a/x-pack/plugins/files/server/routes/file_kind/update.ts b/x-pack/plugins/files/server/routes/file_kind/update.ts index 733f9c9ce78c2..9621fc56c311c 100644 --- a/x-pack/plugins/files/server/routes/file_kind/update.ts +++ b/x-pack/plugins/files/server/routes/file_kind/update.ts @@ -26,7 +26,7 @@ const rt = { }), }; -export type Endpoint = CreateRouteDefinition; +export type Endpoint = CreateRouteDefinition }>; export const handler: CreateHandler = async ({ files, fileKind }, req, res) => { const { fileService } = await files; diff --git a/x-pack/plugins/files/server/routes/public_facing/download.ts b/x-pack/plugins/files/server/routes/public_facing/download.ts index bd739f93077fe..bd77cabaf7e4d 100644 --- a/x-pack/plugins/files/server/routes/public_facing/download.ts +++ b/x-pack/plugins/files/server/routes/public_facing/download.ts @@ -43,7 +43,7 @@ const handler: CreateHandler = async ({ files }, req, res) => { const body: Readable = await file.downloadContent(); return res.ok({ body, - headers: getDownloadHeadersForFile(file, fileName), + headers: getDownloadHeadersForFile({ file, fileName }), }); } catch (e) { if ( diff --git a/yarn.lock b/yarn.lock index bc5e11fba60fa..85da7dff0448c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10559,6 +10559,11 @@ bluebird@3.7.2, bluebird@^3.3.5, bluebird@^3.5.5, bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +blurhash@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.1.tgz#7f134ad0cf3cbb6bcceb81ea51b82e1423009dca" + integrity sha512-qAJW99ZIEVJqLKvR6EUtMavaalYiFgfHNvwO6eiqHE7RTBZYGQLPJvzs4WlnqSQPxZgqSPH/n4kRJIHzb/Y7dg== + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.9: version "4.11.9" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"