183 lines
5.5 KiB
TypeScript
183 lines
5.5 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import type { ForwardedRef, RefObject } from 'react';
|
|
import React, { useRef, useEffect, useState, forwardRef } from 'react';
|
|
import classNames from 'classnames';
|
|
import { isFocusable } from '@react-aria/focus';
|
|
import { strictAssert } from '../../../util/assert';
|
|
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
|
import type { FunImageAriaProps } from '../types';
|
|
|
|
export type FunImageProps = FunImageAriaProps &
|
|
Readonly<{
|
|
className?: string;
|
|
src: string;
|
|
width: number;
|
|
height: number;
|
|
ignoreReducedMotion?: boolean;
|
|
}>;
|
|
|
|
export function FunImage(props: FunImageProps): JSX.Element {
|
|
if (props.ignoreReducedMotion) {
|
|
return <FunImageBase {...props} />;
|
|
}
|
|
return <FunImageReducedMotion {...props} />;
|
|
}
|
|
|
|
/** @internal */
|
|
const FunImageBase = forwardRef(function FunImageBase(
|
|
props: FunImageProps,
|
|
ref: ForwardedRef<HTMLImageElement>
|
|
) {
|
|
return (
|
|
<img
|
|
ref={ref}
|
|
role={props.role}
|
|
aria-label={props['aria-label']}
|
|
className={props.className}
|
|
src={props.src}
|
|
width={props.width}
|
|
height={props.height}
|
|
draggable={false}
|
|
decoding="async"
|
|
loading="lazy"
|
|
/>
|
|
);
|
|
});
|
|
|
|
/** @internal */
|
|
function FunImageReducedMotion(props: FunImageProps) {
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
const intent = useIntent(imageRef);
|
|
const [staticSource, setStaticSource] = useState<string | null>(null);
|
|
const reducedMotion = useReducedMotion();
|
|
|
|
useEffect(() => {
|
|
// Don't bother creating the static source if we're not in reduced motion
|
|
if (!reducedMotion) {
|
|
return;
|
|
}
|
|
|
|
strictAssert(imageRef.current, 'Expected imageRef to be set');
|
|
const image = imageRef.current;
|
|
const controller = new AbortController();
|
|
const { signal } = controller;
|
|
|
|
async function onLoad() {
|
|
const blob = await createStaticImageBlob(image, signal);
|
|
const url = URL.createObjectURL(blob);
|
|
setStaticSource(url);
|
|
}
|
|
|
|
image.addEventListener('load', onLoad, { once: true, signal });
|
|
return () => {
|
|
controller.abort();
|
|
image.removeEventListener('load', onLoad);
|
|
};
|
|
}, [props.src, reducedMotion]);
|
|
|
|
// Ensure we always revoke the object URL.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (staticSource != null) {
|
|
URL.revokeObjectURL(staticSource);
|
|
}
|
|
};
|
|
}, [staticSource]);
|
|
|
|
return (
|
|
<picture
|
|
// This element renders as `display: contents` so the <img/> tag is
|
|
// always responsible for the layout/styles, this makes it a very good
|
|
// drop-in for a plain <img/> tag
|
|
className={classNames('FunImage', {
|
|
// Only hide the image if we're in reduced motion mode and
|
|
// we haven't loaded the static source yet.
|
|
'FunImage--Hidden': reducedMotion && staticSource == null,
|
|
})}
|
|
>
|
|
{staticSource != null && reducedMotion && !intent && (
|
|
<source className="FunImage--StaticSource" srcSet={staticSource} />
|
|
)}
|
|
<FunImageBase {...props} ref={imageRef} />
|
|
</picture>
|
|
);
|
|
}
|
|
|
|
/** Similar to `element.closest()` but with a predicate instead of selectors */
|
|
function closestElement(
|
|
element: HTMLElement,
|
|
predicate: (element: HTMLElement) => boolean
|
|
): HTMLElement | null {
|
|
let search: HTMLElement | null = element;
|
|
while (search != null) {
|
|
if (predicate(search)) {
|
|
return search;
|
|
}
|
|
search = search.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Tracks the closest focusable ancestor for user "intent" (focus/hover within).
|
|
*
|
|
* - Uses the nearest "focusable" element, even if it is not "tabbable", so it
|
|
* should not be affected by `tabIndex` or `disabled` attributes.
|
|
* - React doesn't support "reparenting" so we don't need to worry about the
|
|
* ancestors changing on us.
|
|
* - However, this will break if elements become focusable/unfocusable during
|
|
* their lifetime (this is generally a sign something is being done wrong).
|
|
*/
|
|
export function useIntent(ref: RefObject<HTMLElement>): boolean {
|
|
const [intent, setIntent] = useState(false);
|
|
|
|
useEffect(() => {
|
|
strictAssert(ref.current, 'Expected ref to be set');
|
|
const target = ref.current;
|
|
const focusable = closestElement(target, isFocusable);
|
|
strictAssert(focusable, 'Expected focusable ancestor to be found');
|
|
|
|
function onIntent() {
|
|
setIntent(true);
|
|
}
|
|
|
|
function onDetent() {
|
|
setIntent(false);
|
|
}
|
|
|
|
focusable.addEventListener('focusin', onIntent);
|
|
focusable.addEventListener('mouseenter', onIntent);
|
|
focusable.addEventListener('focusout', onDetent);
|
|
focusable.addEventListener('mouseleave', onDetent);
|
|
|
|
return () => {
|
|
focusable.removeEventListener('focusin', onIntent);
|
|
focusable.removeEventListener('mouseenter', onIntent);
|
|
focusable.removeEventListener('focusout', onDetent);
|
|
focusable.removeEventListener('mouseleave', onDetent);
|
|
};
|
|
}, [ref]);
|
|
|
|
return intent;
|
|
}
|
|
|
|
/**
|
|
* Given any <img>, bitmap, blob, etc. create a static image blob even if it's
|
|
* animated.
|
|
*/
|
|
async function createStaticImageBlob(
|
|
image: ImageBitmapSource,
|
|
signal: AbortSignal
|
|
): Promise<Blob> {
|
|
const bitmap = await createImageBitmap(image);
|
|
signal.throwIfAborted();
|
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
const context = canvas.getContext('bitmaprenderer');
|
|
strictAssert(context, 'Failed to load bitmaprenderer context');
|
|
context.transferFromImageBitmap(bitmap);
|
|
const blob = await canvas.convertToBlob();
|
|
signal.throwIfAborted();
|
|
return blob;
|
|
}
|