Introduce ImageOrBlurhash component
This commit is contained in:
parent
85f472741b
commit
313d832542
4 changed files with 246 additions and 10 deletions
67
ts/components/ImageOrBlurhash.stories.tsx
Normal file
67
ts/components/ImageOrBlurhash.stories.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import type { Props } from './ImageOrBlurhash';
|
||||
import { ImageOrBlurhash } from './ImageOrBlurhash';
|
||||
|
||||
export default {
|
||||
title: 'Components/ImageOrBlurhash',
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
export function JustImage(): JSX.Element {
|
||||
return (
|
||||
<ImageOrBlurhash
|
||||
src="/fixtures/kitten-1-64-64.jpg"
|
||||
width={128}
|
||||
height={128}
|
||||
alt="test"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function JustBlurHash(): JSX.Element {
|
||||
return (
|
||||
<ImageOrBlurhash
|
||||
blurHash="LDA,FDBnm+I=p{tkIUI;~UkpELV]"
|
||||
width={128}
|
||||
height={128}
|
||||
alt="test"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WideBlurHash(): JSX.Element {
|
||||
return (
|
||||
<ImageOrBlurhash
|
||||
blurHash="LDA,FDBnm+I=p{tkIUI;~UkpELV]"
|
||||
width={300}
|
||||
height={65}
|
||||
alt="test"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TallBlurHash(): JSX.Element {
|
||||
return (
|
||||
<ImageOrBlurhash
|
||||
blurHash="LDA,FDBnm+I=p{tkIUI;~UkpELV]"
|
||||
width={64}
|
||||
height={256}
|
||||
alt="test"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullImage(): JSX.Element {
|
||||
return (
|
||||
<ImageOrBlurhash
|
||||
src="/fixtures/kitten-1-64-64.jpg"
|
||||
blurHash="LDA,FDBnm+I=p{tkIUI;~UkpELV]"
|
||||
width={128}
|
||||
height={128}
|
||||
alt="test"
|
||||
/>
|
||||
);
|
||||
}
|
52
ts/components/ImageOrBlurhash.tsx
Normal file
52
ts/components/ImageOrBlurhash.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { computeBlurHashUrl } from '../util/computeBlurHashUrl';
|
||||
|
||||
export type Props = React.ImgHTMLAttributes<HTMLImageElement> &
|
||||
Readonly<{
|
||||
blurHash?: string;
|
||||
alt: string;
|
||||
intrinsicWidth?: number;
|
||||
intrinsicHeight?: number;
|
||||
}>;
|
||||
|
||||
export function ImageOrBlurhash({
|
||||
src: imageSrc,
|
||||
blurHash,
|
||||
alt,
|
||||
intrinsicWidth,
|
||||
intrinsicHeight,
|
||||
...rest
|
||||
}: Props): JSX.Element {
|
||||
const blurHashUrl = useMemo(() => {
|
||||
return blurHash
|
||||
? computeBlurHashUrl(blurHash, intrinsicWidth, intrinsicHeight)
|
||||
: undefined;
|
||||
}, [blurHash, intrinsicWidth, intrinsicHeight]);
|
||||
|
||||
const src = imageSrc ?? blurHashUrl;
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={src}
|
||||
alt={alt}
|
||||
style={{
|
||||
// Use a background image with an data url of the blurhash which should
|
||||
// show quickly and stay visible until the img src is loaded/decoded.
|
||||
backgroundImage:
|
||||
blurHashUrl != null && blurHashUrl !== src
|
||||
? `url(${blurHashUrl})`
|
||||
: 'none',
|
||||
|
||||
// Preserve aspect ratio
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
loading={blurHashUrl != null ? 'lazy' : 'eager'}
|
||||
decoding={blurHashUrl != null ? 'async' : 'auto'}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -4,8 +4,8 @@
|
|||
import type { CSSProperties } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
|
||||
import { ImageOrBlurhash } from '../ImageOrBlurhash';
|
||||
import { Spinner } from '../Spinner';
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import type { AttachmentForUIType } from '../../types/Attachment';
|
||||
|
@ -169,21 +169,17 @@ export function Image({
|
|||
showMediaNoLongerAvailableToast
|
||||
);
|
||||
|
||||
const imageOrBlurHash = url ? (
|
||||
<img
|
||||
const imageOrBlurHash = (
|
||||
<ImageOrBlurhash
|
||||
onError={onError}
|
||||
className="module-image__image"
|
||||
alt={alt}
|
||||
height={height}
|
||||
width={width}
|
||||
intrinsicWidth={attachment.width}
|
||||
intrinsicHeight={attachment.height}
|
||||
src={url}
|
||||
/>
|
||||
) : (
|
||||
<Blurhash
|
||||
hash={resolvedBlurHash}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
blurHash={resolvedBlurHash}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
121
ts/util/computeBlurHashUrl.ts
Normal file
121
ts/util/computeBlurHashUrl.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { decode } from 'blurhash';
|
||||
import * as Bytes from '../Bytes';
|
||||
|
||||
const BITMAP_HEADER = new Uint8Array([
|
||||
// Header
|
||||
// See https://en.wikipedia.org/wiki/BMP_file_format#Bitmap_file_header
|
||||
|
||||
// 0x00: BM
|
||||
0x42, 0x4d,
|
||||
// 0x02: Size = 3072 + 14 + 40
|
||||
0x36, 0x0c, 0x00, 0x00,
|
||||
// 0x06: Reserved
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// 0x0a: Pixels Offset = 14 + 40
|
||||
0x36, 0x00, 0x00, 0x00,
|
||||
|
||||
// BIP Header
|
||||
// See https://en.wikipedia.org/wiki/BMP_file_format#cite_ref-bmp_2-2
|
||||
|
||||
// 0x0e: Size=40
|
||||
0x28, 0x00, 0x00, 0x00,
|
||||
|
||||
// 0x12: Width=32
|
||||
0x20, 0x00, 0x00, 0x00,
|
||||
// 0x16: Height=-32 (top-down)
|
||||
0xe0, 0xff, 0xff, 0xff,
|
||||
// 0x1a: Num Color Planes
|
||||
0x01, 0x00,
|
||||
// 0x1c: Bits per Pixel = 24
|
||||
0x18, 0x00,
|
||||
// 0x1e: Compression Method
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// 0x22: Image size = 3072
|
||||
0x00, 0x0c, 0x00, 0x00,
|
||||
// 0x26: Horizontal Resolution
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
// 0x2a: Vertical Resolution
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
// 0x2e: Number of Colors in Palette
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// 0x32: Number of Important Colors
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
const PIXEL_COUNT = 32 * 32;
|
||||
|
||||
/* eslint-disable no-bitwise */
|
||||
function writeUInt32LE(bytes: Uint8Array, value: number, position: number) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
bytes[position + 0] = (value >>> 0) & 0xff;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
bytes[position + 1] = (value >>> 8) & 0xff;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
bytes[position + 2] = (value >>> 16) & 0xff;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
bytes[position + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
export function computeBlurHashUrl(
|
||||
blurHash: string,
|
||||
// Square by default
|
||||
desiredWidth = 1,
|
||||
desiredHeight = 1
|
||||
): string {
|
||||
const invAspect = Math.abs(desiredHeight) / (Math.abs(desiredWidth) + 1e-23);
|
||||
|
||||
// Calculate width and height that roughly satisfy the desired PIXEL_COUNT
|
||||
//
|
||||
// height = invAspect * width
|
||||
// width * height = invAspect * width * width = PIXEL_COUNT
|
||||
let width = Math.sqrt(PIXEL_COUNT / (invAspect + 1e-23));
|
||||
width = Math.round(width);
|
||||
|
||||
// Width has to be a multiple of DWORD size (4) for BMP to render image
|
||||
// correctly
|
||||
width >>= 2;
|
||||
width <<= 2;
|
||||
|
||||
// Give at least two pixels of width to show gradients
|
||||
width = Math.max(2, width);
|
||||
|
||||
let height = width * invAspect;
|
||||
height = Math.round(height);
|
||||
|
||||
// Minimum two pixels of height for gradients
|
||||
height = Math.max(2, height);
|
||||
|
||||
const rgba = decode(blurHash, width, height);
|
||||
const bgrSize = (rgba.byteLength / 4) * 3;
|
||||
const bitmap = new Uint8Array(BITMAP_HEADER.byteLength + bgrSize);
|
||||
|
||||
bitmap.set(BITMAP_HEADER);
|
||||
|
||||
// Update size
|
||||
writeUInt32LE(bitmap, bitmap.byteLength, 0x02);
|
||||
|
||||
// Update width and height (has to be negative for top-down drawing)
|
||||
writeUInt32LE(bitmap, width, 0x12);
|
||||
writeUInt32LE(bitmap, -height, 0x16);
|
||||
|
||||
// Update image size
|
||||
writeUInt32LE(bitmap, bgrSize, 0x22);
|
||||
|
||||
// Copy pixels
|
||||
for (
|
||||
let i = 0, j = BITMAP_HEADER.byteLength;
|
||||
i < rgba.byteLength;
|
||||
i += 4, j += 3
|
||||
) {
|
||||
// BMP uses BGR ordering
|
||||
bitmap[j + 2] = rgba[i];
|
||||
bitmap[j + 1] = rgba[i + 1];
|
||||
bitmap[j] = rgba[i + 2];
|
||||
}
|
||||
|
||||
return `data:image/bmp;base64,${Bytes.toBase64(bitmap)}`;
|
||||
}
|
||||
/* eslint-enable no-bitwise */
|
Loading…
Add table
Add a link
Reference in a new issue