Skip to content

Commit

Permalink
[Files] Use blurhash for images (#142493) (#142858)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
(cherry picked from commit dbbf3ad)

Co-authored-by: Jean-Louis Leysens <[email protected]>
  • Loading branch information
kibanamachine and jloleysens authored Oct 6, 2022
1 parent 40b007e commit 15a5982
Show file tree
Hide file tree
Showing 34 changed files with 551 additions and 94 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion x-pack/examples/files_example/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,3 +27,5 @@ export const exampleFileKind: FileKind = {
update: httpTags,
},
};

export type MyImageMetadata = FileImageMetadata;
9 changes: 5 additions & 4 deletions x-pack/examples/files_example/public/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -31,15 +32,15 @@ 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'], () =>
files.example.list()
);
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 [
Expand All @@ -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',
Expand Down
29 changes: 19 additions & 10 deletions x-pack/examples/files_example/public/components/details_flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -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',
Expand Down Expand Up @@ -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">
Expand Down
4 changes: 2 additions & 2 deletions x-pack/examples/files_example/public/components/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions x-pack/examples/files_example/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions x-pack/examples/files_example/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* 2.0.
*/

import { MyImageMetadata } from '../common';
import type { FilesSetup, FilesStart, ScopedFilesClient, FilesClient } from './imports';

export interface FilesExamplePluginsSetup {
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/files/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
FileSavedObject,
BaseFileMetadata,
FileShareOptions,
FileImageMetadata,
FileUnshareOptions,
BlobStorageSettings,
UpdatableFileMetadata,
Expand Down
19 changes: 19 additions & 0 deletions x-pack/plugins/files/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 0 additions & 1 deletion x-pack/plugins/files/public/components/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export const useFilesContext = () => {
}
return ctx;
};

export const FilesContext: FunctionComponent = ({ children }) => {
return (
<FilesContextObject.Provider
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
/>
);
};
50 changes: 50 additions & 0 deletions x-pack/plugins/files/public/components/image/components/img.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
}}
/>
);
}
);
10 changes: 10 additions & 0 deletions x-pack/plugins/files/public/components/image/components/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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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`;
Loading

0 comments on commit 15a5982

Please sign in to comment.