diff --git a/apps/studio/compat/next/image.tsx b/apps/studio/compat/next/image.tsx index db4cae97c18..69c19e3846e 100644 --- a/apps/studio/compat/next/image.tsx +++ b/apps/studio/compat/next/image.tsx @@ -1,28 +1,120 @@ -import type { ComponentPropsWithoutRef, CSSProperties } from 'react' +import { + forwardRef, + useEffect, + useRef, + type ComponentPropsWithoutRef, + type CSSProperties, + type ForwardedRef, + type SyntheticEvent, +} from 'react' -type ImageProps = Omit, 'src' | 'alt'> & { - src: string +// Next/Image is a smart wrapper around `` that handles automatic +// resizing, lazy loading, blur placeholders, and a CDN loader. Under +// Vite we don't run the Next image optimizer, so this shim degrades to +// a plain `` while preserving the prop surface so consumer code +// compiles without modification. + +type ImageLoaderProps = { src: string; width: number; quality?: number } +type ImageLoader = (props: ImageLoaderProps) => string + +interface ImageProps extends Omit, 'src' | 'alt' | 'loading'> { + src: string | { src: string; width?: number; height?: number } alt: string + width?: number | `${number}` + height?: number | `${number}` fill?: boolean sizes?: string priority?: boolean - quality?: number - placeholder?: 'blur' | 'empty' + loading?: 'lazy' | 'eager' + quality?: number | `${number}` + loader?: ImageLoader + placeholder?: 'blur' | 'empty' | `data:image/${string}` blurDataURL?: string + unoptimized?: boolean + onLoadingComplete?: (img: HTMLImageElement) => void } -export default function Image({ - fill, - sizes: _sizes, - priority: _priority, - quality: _quality, - placeholder: _placeholder, - blurDataURL: _blurDataURL, - style, - ...rest -}: ImageProps) { +function resolveSrc( + src: ImageProps['src'], + width?: ImageProps['width'], + quality?: ImageProps['quality'], + loader?: ImageLoader +): string { + const raw = typeof src === 'string' ? src : src.src + if (!loader) return raw + return loader({ + src: raw, + width: typeof width === 'number' ? width : Number(width ?? 0), + quality: quality !== undefined ? Number(quality) : undefined, + }) +} + +const Image = forwardRef(function Image( + { + src, + width, + height, + fill, + sizes, + priority, + loading, + quality, + loader, + placeholder: _placeholder, + blurDataURL: _blurDataURL, + unoptimized: _unoptimized, + onLoad, + onLoadingComplete, + style, + ...rest + }: ImageProps, + forwardedRef: ForwardedRef +) { + const innerRef = useRef(null) + + // Mirror Next's onLoadingComplete by firing once when the image has + // finished decoding. Next deduplicates against re-fires; we approximate + // by firing on the load event (the practical observable difference is + // negligible for our bundle). + const handleLoad = (e: SyntheticEvent) => { + onLoad?.(e) + if (onLoadingComplete && e.currentTarget) { + onLoadingComplete(e.currentTarget) + } + } + + // If onLoadingComplete is provided and the image is already cached + // (loaded synchronously before our handler attaches), fire it on + // mount so the contract holds. + useEffect(() => { + const img = innerRef.current + if (!onLoadingComplete || !img) return + if (img.complete && img.naturalWidth > 0) onLoadingComplete(img) + }, [onLoadingComplete]) + const finalStyle: CSSProperties | undefined = fill ? { position: 'absolute', inset: 0, width: '100%', height: '100%', ...style } : style - return -} + + return ( + { + innerRef.current = node + if (typeof forwardedRef === 'function') forwardedRef(node) + else if (forwardedRef) forwardedRef.current = node + }} + src={resolveSrc(src, width, quality, loader)} + width={fill ? undefined : width} + height={fill ? undefined : height} + sizes={sizes} + // Match Next's defaults: lazy unless priority/loading says otherwise. + loading={loading ?? (priority ? 'eager' : 'lazy')} + fetchPriority={priority ? 'high' : rest.fetchPriority} + style={finalStyle} + onLoad={handleLoad} + /> + ) +}) + +export default Image diff --git a/apps/studio/compat/next/legacy/image.tsx b/apps/studio/compat/next/legacy/image.tsx index 470eb3b8d7c..fafb20a909a 100644 --- a/apps/studio/compat/next/legacy/image.tsx +++ b/apps/studio/compat/next/legacy/image.tsx @@ -1,33 +1,96 @@ -import type { ComponentPropsWithoutRef, CSSProperties } from 'react' +import { + forwardRef, + useEffect, + useRef, + type ComponentPropsWithoutRef, + type CSSProperties, + type ForwardedRef, + type SyntheticEvent, +} from 'react' + +// `next/legacy/image` is the pre-Next-13 Image API. Functionally similar +// to `next/image` but with `layout` and `objectFit`/`objectPosition` +// props instead of `fill` + style. Same shim approach: degrade to a +// plain with the prop surface preserved. + +type ImageLoaderProps = { src: string; width: number; quality?: number } +type ImageLoader = (props: ImageLoaderProps) => string type Layout = 'fill' | 'fixed' | 'intrinsic' | 'responsive' type ObjectFit = CSSProperties['objectFit'] -type ImageProps = Omit, 'src' | 'alt'> & { - src: string +interface ImageProps extends Omit, 'src' | 'alt' | 'loading'> { + src: string | { src: string } alt: string + width?: number | `${number}` + height?: number | `${number}` layout?: Layout objectFit?: ObjectFit objectPosition?: CSSProperties['objectPosition'] priority?: boolean - quality?: number + loading?: 'lazy' | 'eager' + quality?: number | `${number}` + loader?: ImageLoader placeholder?: 'blur' | 'empty' blurDataURL?: string + unoptimized?: boolean sizes?: string + onLoadingComplete?: (img: HTMLImageElement) => void } -export default function Image({ - layout, - objectFit, - objectPosition, - priority: _priority, - quality: _quality, - placeholder: _placeholder, - blurDataURL: _blurDataURL, - sizes: _sizes, - style, - ...rest -}: ImageProps) { +function resolveSrc( + src: ImageProps['src'], + width?: ImageProps['width'], + quality?: ImageProps['quality'], + loader?: ImageLoader +): string { + const raw = typeof src === 'string' ? src : src.src + if (!loader) return raw + return loader({ + src: raw, + width: typeof width === 'number' ? width : Number(width ?? 0), + quality: quality !== undefined ? Number(quality) : undefined, + }) +} + +const Image = forwardRef(function Image( + { + src, + width, + height, + layout, + objectFit, + objectPosition, + priority, + loading, + quality, + loader, + placeholder: _placeholder, + blurDataURL: _blurDataURL, + unoptimized: _unoptimized, + sizes, + onLoad, + onLoadingComplete, + style, + ...rest + }: ImageProps, + forwardedRef: ForwardedRef +) { + const innerRef = useRef(null) + + const handleLoad = (e: SyntheticEvent) => { + onLoad?.(e) + if (onLoadingComplete && e.currentTarget) { + onLoadingComplete(e.currentTarget) + } + } + + useEffect(() => { + const img = innerRef.current + if (!onLoadingComplete || !img) return + if (img.complete && img.naturalWidth > 0) onLoadingComplete(img) + }, [onLoadingComplete]) + const layoutStyle: CSSProperties | undefined = layout === 'fill' ? { position: 'absolute', inset: 0, width: '100%', height: '100%' } @@ -40,5 +103,24 @@ export default function Image({ ? { ...layoutStyle, objectFit, objectPosition, ...style } : undefined - return -} + return ( + { + innerRef.current = node + if (typeof forwardedRef === 'function') forwardedRef(node) + else if (forwardedRef) forwardedRef.current = node + }} + src={resolveSrc(src, width, quality, loader)} + width={layout === 'fill' ? undefined : width} + height={layout === 'fill' ? undefined : height} + sizes={sizes} + loading={loading ?? (priority ? 'eager' : 'lazy')} + fetchPriority={priority ? 'high' : rest.fetchPriority} + style={finalStyle} + onLoad={handleLoad} + /> + ) +}) + +export default Image