mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Files] Use blurhash for images (#142493)
* 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>
This commit is contained in:
parent
f026f5ff2a
commit
dbbf3ad42b
34 changed files with 550 additions and 93 deletions
|
@ -445,6 +445,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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<MyImageMetadata>['list'];
|
||||
|
||||
export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) => {
|
||||
const { data, isLoading, error, refetch } = useQuery<ListResponse>(['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<undefined | FileJSON>();
|
||||
const [selectedItem, setSelectedItem] = useState<undefined | FileJSON<MyImageMetadata>>();
|
||||
|
||||
const renderToolsRight = () => {
|
||||
return [
|
||||
|
@ -55,7 +56,7 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) =
|
|||
|
||||
const items = [...(data?.files ?? [])].reverse();
|
||||
|
||||
const columns: EuiInMemoryTableProps<FileJSON>['columns'] = [
|
||||
const columns: EuiInMemoryTableProps<FileJSON<MyImageMetadata>>['columns'] = [
|
||||
{
|
||||
field: 'name',
|
||||
name: 'Name',
|
||||
|
|
|
@ -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<MyImageMetadata>;
|
||||
files: FileClients;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
@ -40,8 +41,24 @@ export const DetailsFlyout: FunctionComponent<Props> = ({ files, file, onDismiss
|
|||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<div
|
||||
css={css`
|
||||
display: grid;
|
||||
place-items: center;
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
size="l"
|
||||
alt={file.alt ?? 'unknown'}
|
||||
src={files.example.getDownloadHref(file)}
|
||||
meta={file.meta}
|
||||
/>
|
||||
</div>
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiDescriptionList
|
||||
type="column"
|
||||
align="center"
|
||||
textStyle="reverse"
|
||||
listItems={[
|
||||
{
|
||||
title: 'Name',
|
||||
|
@ -75,14 +92,6 @@ export const DetailsFlyout: FunctionComponent<Props> = ({ files, file, onDismiss
|
|||
},
|
||||
]}
|
||||
/>
|
||||
<EuiSpacer size="xl" />
|
||||
<Image
|
||||
css={css`
|
||||
height: 400px;
|
||||
`}
|
||||
alt={file.alt ?? 'unknown'}
|
||||
src={files.example.getDownloadHref(file)}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
|
||||
|
|
|
@ -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<MyImageMetadata>;
|
||||
onDismiss: () => void;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
|
|
@ -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<MyImageMetadata>(),
|
||||
example: deps.files.filesClientFactory.asScoped<MyImageMetadata>(exampleFileKind.id),
|
||||
},
|
||||
},
|
||||
params
|
||||
|
|
|
@ -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<MyImageMetadata>;
|
||||
// Example file kind
|
||||
example: ScopedFilesClient;
|
||||
example: ScopedFilesClient<MyImageMetadata>;
|
||||
}
|
||||
|
||||
export interface AppPluginStartDependencies {
|
||||
|
|
|
@ -21,6 +21,7 @@ export type {
|
|||
FileSavedObject,
|
||||
BaseFileMetadata,
|
||||
FileShareOptions,
|
||||
FileImageMetadata,
|
||||
FileUnshareOptions,
|
||||
BlobStorageSettings,
|
||||
UpdatableFileMetadata,
|
||||
|
|
|
@ -536,3 +536,22 @@ export interface FilesMetrics {
|
|||
*/
|
||||
countByExtension: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@ export const useFilesContext = () => {
|
|||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const FilesContext: FunctionComponent = ({ children }) => {
|
||||
return (
|
||||
<FilesContextObject.Provider
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { decode } from 'blurhash';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import { useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { fitToBox } from '../../util';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
hash: string;
|
||||
width: number;
|
||||
height: number;
|
||||
isContainerWidth: boolean;
|
||||
}
|
||||
|
||||
export const Blurhash: FunctionComponent<Props> = ({
|
||||
visible,
|
||||
hash,
|
||||
width,
|
||||
height,
|
||||
isContainerWidth,
|
||||
}) => {
|
||||
const ref = useRef<null | HTMLImageElement>(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 (
|
||||
<img
|
||||
alt=""
|
||||
css={css`
|
||||
top: 0;
|
||||
width: ${isContainerWidth ? '100%' : width + 'px'};
|
||||
z-index: -1;
|
||||
|
||||
position: ${visible ? 'unset' : 'absolute'};
|
||||
opacity: ${visible ? 1 : 0};
|
||||
transition: opacity ${euiTheme.animation.extraFast};
|
||||
`}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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<HTMLImageElement> {
|
||||
hidden: boolean;
|
||||
size?: EuiImageSize;
|
||||
observerRef: (el: null | HTMLImageElement) => void;
|
||||
}
|
||||
|
||||
export const Img = React.forwardRef<HTMLImageElement, Props>(
|
||||
({ 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 (
|
||||
<img
|
||||
alt=""
|
||||
css={styles}
|
||||
{...rest}
|
||||
src={src}
|
||||
ref={(element) => {
|
||||
observerRef(element);
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') ref(element);
|
||||
else (ref as MutableRefObject<HTMLImageElement | null>).current = element;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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';
|
File diff suppressed because one or more lines are too long
|
@ -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,
|
||||
};
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<FilesContext>
|
||||
<Story />
|
||||
</FilesContext>
|
||||
),
|
||||
],
|
||||
} as ComponentMeta<typeof Image>;
|
||||
|
||||
const baseStyle = css`
|
||||
width: 400px;
|
||||
`;
|
||||
|
||||
const Template: ComponentStory<typeof Image> = (props: Props) => (
|
||||
<Image css={baseStyle} {...props} ref={action('ref')} />
|
||||
const Template: ComponentStory<typeof Image> = (props: Props, { loaded: { meta } }) => (
|
||||
<Image size="original" {...props} meta={meta} ref={action('ref')} />
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<style>{alwaysShowBlurhash}</style>
|
||||
<Story />
|
||||
</>
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
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) => (
|
||||
<>
|
||||
|
|
|
@ -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<HTMLImageElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
/**
|
||||
* Image metadata
|
||||
*/
|
||||
meta?: FileImageMetadata;
|
||||
|
||||
/**
|
||||
* @default original
|
||||
*/
|
||||
size?: ImgProps['size'];
|
||||
/**
|
||||
* Props to pass to the wrapper element
|
||||
*/
|
||||
wrapperProps?: HTMLAttributes<HTMLDivElement>;
|
||||
/**
|
||||
* Emits when the image first becomes visible
|
||||
*/
|
||||
|
@ -28,22 +45,65 @@ export interface Props extends ImgHTMLAttributes<HTMLImageElement> {
|
|||
* ```
|
||||
*/
|
||||
export const Image = React.forwardRef<HTMLImageElement, Props>(
|
||||
({ src, alt, onFirstVisible, ...rest }, ref) => {
|
||||
(
|
||||
{ src, alt, onFirstVisible, onLoad, onError, meta, wrapperProps, size = 'original', ...rest },
|
||||
ref
|
||||
) => {
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(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 (
|
||||
<img
|
||||
{...rest}
|
||||
ref={(element) => {
|
||||
observerRef(element);
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') ref(element);
|
||||
else (ref as MutableRefObject<HTMLImageElement | null>).current = element;
|
||||
}
|
||||
}}
|
||||
// TODO: We should have a lower resolution alternative to display
|
||||
src={isVisible ? src : undefined}
|
||||
alt={alt}
|
||||
/>
|
||||
<div
|
||||
css={[
|
||||
css`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`,
|
||||
knownSize,
|
||||
]}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{blurDelayExpired && meta?.blurhash && (
|
||||
<Blurhash
|
||||
visible={!isLoaded}
|
||||
hash={meta.blurhash}
|
||||
isContainerWidth={size !== 'original' && size !== undefined}
|
||||
width={meta.width}
|
||||
height={meta.height}
|
||||
/>
|
||||
)}
|
||||
<Img
|
||||
observerRef={observerRef}
|
||||
ref={ref}
|
||||
size={size}
|
||||
hidden={!isVisible}
|
||||
src={isVisible ? src : undefined}
|
||||
alt={alt}
|
||||
onLoad={(ev) => {
|
||||
setIsLoaded(true);
|
||||
onLoad?.(ev);
|
||||
}}
|
||||
onError={(ev) => {
|
||||
setIsLoaded(true);
|
||||
onError?.(ev);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
30
x-pack/plugins/files/public/components/image/styles.ts
Normal file
30
x-pack/plugins/files/public/components/image/styles.ts
Normal file
|
@ -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%;
|
||||
`,
|
||||
};
|
|
@ -40,7 +40,7 @@ export interface Props<Kind extends string = string> {
|
|||
/**
|
||||
* A files client that will be used process uploads.
|
||||
*/
|
||||
client: FilesClient;
|
||||
client: FilesClient<any>;
|
||||
/**
|
||||
* Allow users to clear a file after uploading.
|
||||
*
|
||||
|
|
|
@ -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<FilesClient>;
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
const _meta = meta as Record<string, unknown>;
|
||||
|
||||
return from(
|
||||
this.client.create({
|
||||
kind: this.fileKind.id,
|
||||
name,
|
||||
mimeType: mime,
|
||||
meta: meta as Record<string, unknown>,
|
||||
})
|
||||
).pipe(
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<HTMLImageElement> {
|
||||
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<undefined | FileImageMetadata> {
|
||||
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;
|
9
x-pack/plugins/files/public/components/util/index.ts
Normal file
9
x-pack/plugins/files/public/components/util/index.ts
Normal file
|
@ -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';
|
|
@ -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<FilesSetup, FilesStart> {
|
|||
|
||||
setup(core: CoreSetup): FilesSetup {
|
||||
this.filesClientFactory = {
|
||||
asScoped(fileKind: string) {
|
||||
return createFilesClient({ fileKind, http: core.http });
|
||||
asScoped<M = unknown>(fileKind: string) {
|
||||
return createFilesClient({ fileKind, http: core.http }) as ScopedFilesClient<M>;
|
||||
},
|
||||
asUnscoped() {
|
||||
return createFilesClient({ http: core.http });
|
||||
asUnscoped<M>() {
|
||||
return createFilesClient({ http: core.http }) as FilesClient<M>;
|
||||
},
|
||||
};
|
||||
return {
|
||||
|
|
|
@ -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<M = unknown> extends GlobalEndpoints {
|
||||
/**
|
||||
* Create a new file object with the provided metadata.
|
||||
*
|
||||
* @param args - create file args
|
||||
*/
|
||||
create: ClientMethodFrom<CreateFileKindHttpEndpoint>;
|
||||
create: ClientMethodFrom<CreateFileKindHttpEndpoint<M>>;
|
||||
/**
|
||||
* 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<GetByIdFileKindHttpEndpoint>;
|
||||
getById: ClientMethodFrom<GetByIdFileKindHttpEndpoint<M>>;
|
||||
/**
|
||||
* List all file objects, of a given {@link FileKind}.
|
||||
*
|
||||
* @param args - list files args
|
||||
*/
|
||||
list: ClientMethodFrom<ListFileKindHttpEndpoint>;
|
||||
list: ClientMethodFrom<ListFileKindHttpEndpoint<M>>;
|
||||
/**
|
||||
* Update a set of of metadata values of the file object.
|
||||
*
|
||||
* @param args - update file args
|
||||
*/
|
||||
update: ClientMethodFrom<UpdateFileKindHttpEndpoint>;
|
||||
update: ClientMethodFrom<UpdateFileKindHttpEndpoint<M>>;
|
||||
/**
|
||||
* Stream the contents of the file to Kibana server for storage.
|
||||
*
|
||||
|
@ -151,8 +151,8 @@ export interface FilesClient extends GlobalEndpoints {
|
|||
listShares: ClientMethodFrom<FileListSharesHttpEndpoint>;
|
||||
}
|
||||
|
||||
export type FilesClientResponses = {
|
||||
[K in keyof FilesClient]: Awaited<ReturnType<FilesClient[K]>>;
|
||||
export type FilesClientResponses<M = unknown> = {
|
||||
[K in keyof FilesClient]: Awaited<ReturnType<FilesClient<M>[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<M = unknown> = {
|
||||
[K in keyof FilesClient]: K extends 'list'
|
||||
? (arg?: Omit<Parameters<FilesClient[K]>[0], 'kind'>) => ReturnType<FilesClient[K]>
|
||||
: (arg: Omit<Parameters<FilesClient[K]>[0], 'kind'>) => ReturnType<FilesClient[K]>;
|
||||
? (arg?: Omit<Parameters<FilesClient<M>[K]>[0], 'kind'>) => ReturnType<FilesClient<M>[K]>
|
||||
: (arg: Omit<Parameters<FilesClient<M>[K]>[0], 'kind'>) => ReturnType<FilesClient<M>[K]>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -174,11 +174,11 @@ export interface FilesClientFactory {
|
|||
/**
|
||||
* Create a files client.
|
||||
*/
|
||||
asUnscoped(): FilesClient;
|
||||
asUnscoped<M = unknown>(): FilesClient<M>;
|
||||
/**
|
||||
* Create a {@link ScopedFileClient} for a given {@link FileKind}.
|
||||
*
|
||||
* @param fileKind - The {@link FileKind} to create a client for.
|
||||
*/
|
||||
asScoped(fileKind: string): ScopedFilesClient;
|
||||
asScoped<M = unknown>(fileKind: string): ScopedFilesClient<M>;
|
||||
}
|
||||
|
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -23,7 +23,7 @@ const rt = {
|
|||
}),
|
||||
};
|
||||
|
||||
export type Endpoint = CreateRouteDefinition<typeof rt, { file: FileJSON }>;
|
||||
export type Endpoint<M = unknown> = CreateRouteDefinition<typeof rt, { file: FileJSON<M> }>;
|
||||
|
||||
export const handler: CreateHandler<Endpoint> = async ({ fileKind, files }, req, res) => {
|
||||
const { fileService } = await files;
|
||||
|
|
|
@ -40,7 +40,7 @@ export const handler: CreateHandler<Endpoint> = 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) {
|
||||
|
|
|
@ -19,7 +19,7 @@ const rt = {
|
|||
}),
|
||||
};
|
||||
|
||||
export type Endpoint = CreateRouteDefinition<typeof rt, { file: FileJSON }>;
|
||||
export type Endpoint<M = unknown> = CreateRouteDefinition<typeof rt, { file: FileJSON<M> }>;
|
||||
|
||||
export const handler: CreateHandler<Endpoint> = async ({ files, fileKind }, req, res) => {
|
||||
const { fileService } = await files;
|
||||
|
|
|
@ -18,7 +18,7 @@ const rt = {
|
|||
}),
|
||||
};
|
||||
|
||||
export type Endpoint = CreateRouteDefinition<typeof rt, { files: FileJSON[] }>;
|
||||
export type Endpoint<M = unknown> = CreateRouteDefinition<typeof rt, { files: Array<FileJSON<M>> }>;
|
||||
|
||||
export const handler: CreateHandler<Endpoint> = async ({ files, fileKind }, req, res) => {
|
||||
const {
|
||||
|
|
|
@ -26,7 +26,7 @@ const rt = {
|
|||
}),
|
||||
};
|
||||
|
||||
export type Endpoint = CreateRouteDefinition<typeof rt, { file: FileJSON }>;
|
||||
export type Endpoint<M = unknown> = CreateRouteDefinition<typeof rt, { file: FileJSON<M> }>;
|
||||
|
||||
export const handler: CreateHandler<Endpoint> = async ({ files, fileKind }, req, res) => {
|
||||
const { fileService } = await files;
|
||||
|
|
|
@ -43,7 +43,7 @@ const handler: CreateHandler<Endpoint> = 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 (
|
||||
|
|
|
@ -10852,6 +10852,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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue