[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:
Jean-Louis Leysens 2022-10-06 15:57:22 +02:00 committed by GitHub
parent f026f5ff2a
commit dbbf3ad42b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 550 additions and 93 deletions

View file

@ -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",

View file

@ -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;

View file

@ -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',

View file

@ -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">

View file

@ -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;
}

View file

@ -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

View file

@ -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 {

View file

@ -21,6 +21,7 @@ export type {
FileSavedObject,
BaseFileMetadata,
FileShareOptions,
FileImageMetadata,
FileUnshareOptions,
BlobStorageSettings,
UpdatableFileMetadata,

View file

@ -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;
}

View file

@ -21,7 +21,6 @@ export const useFilesContext = () => {
}
return ctx;
};
export const FilesContext: FunctionComponent = ({ children }) => {
return (
<FilesContextObject.Provider

View file

@ -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}
/>
);
};

View file

@ -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;
}
}}
/>
);
}
);

View file

@ -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

View file

@ -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) => (
<>

View file

@ -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>
);
}
);

View 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%;
`,
};

View file

@ -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.
*

View file

@ -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;

View 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);
};

View file

@ -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,
}
`);
});
});
});

View file

@ -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;

View 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';

View file

@ -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 {

View file

@ -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>;
}

View file

@ -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' })
);
});

View file

@ -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',

View file

@ -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;

View file

@ -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) {

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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 (

View file

@ -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"