diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx index b7a6b264319..d9f85e56a36 100644 --- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx @@ -67,28 +67,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element { const uploadSubscription = useRef(null) - /** - * The upload progress state wants to use the same height as any previous image - * to avoid layout shifts and jumps - */ - const previewElementRef = useRef<{el: HTMLDivElement | null; height: number}>({ - el: null, - height: 0, - }) - const setPreviewElementHeight = useCallback((node: HTMLDivElement | null) => { - if (node) { - previewElementRef.current.el = node - previewElementRef.current.height = node.offsetHeight - } else { - /** - * If `node` is `null` then it means the `FileTarget` in `ImageInputAsset` is being unmounted and we want to - * capture its height before it's removed from the DOM. - */ - - previewElementRef.current.height = previewElementRef.current.el?.offsetHeight || 0 - previewElementRef.current.el = null - } - }, []) const getFileTone = useCallback(() => { const acceptedFiles = hoveringFiles.filter((file) => resolveUploader(schemaType, file)) const rejectedFilesCount = hoveringFiles.length - acceptedFiles.length @@ -201,9 +179,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element { const handleClearField = useCallback(() => { onChange([unset(['asset']), unset(['crop']), unset(['hotspot'])]) - - previewElementRef.current.el = null - previewElementRef.current.height = 0 }, [onChange]) const handleRemoveButtonClick = useCallback(() => { // When removing the image, we should also remove any crop and hotspot @@ -224,9 +199,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element { .map((key) => unset([key])) onChange(isEmpty && !valueIsArrayElement() ? unset() : removeKeys) - - previewElementRef.current.el = null - previewElementRef.current.height = 0 }, [onChange, value, valueIsArrayElement]) const handleOpenDialog = useCallback(() => { onPathFocus(['hotspot']) @@ -303,15 +275,16 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element { menuButtonElement?.focus() }, [menuButtonElement]) - const renderPreview = useCallback(() => { + const renderPreview = useCallback<() => JSX.Element>(() => { + if (!value) { + return <> + } return ( ) }, @@ -420,7 +391,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element { // eslint-disable-next-line react/display-name return (inputProps: Omit) => ( ) }, [ @@ -449,13 +420,13 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element { handleFilesOver, handleSelectFiles, hoveringFiles, + imageUrlBuilder, isStale, readOnly, renderAssetMenu, renderPreview, renderUploadPlaceholder, renderUploadState, - setPreviewElementHeight, value, ]) const renderHotspotInput = useCallback( diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputAsset.tsx b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputAsset.tsx index d59ce2384b8..c07bbfdf903 100644 --- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputAsset.tsx +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputAsset.tsx @@ -1,36 +1,36 @@ import {type UploadState} from '@sanity/types' import {Box, type CardTone} from '@sanity/ui' -import {type FocusEvent, forwardRef, memo, useMemo} from 'react' +import {type FocusEvent, memo, useMemo} from 'react' import {ChangeIndicator} from '../../../../changeIndicators' import {type InputProps} from '../../../types' import {FileTarget} from '../common/styles' import {UploadWarning} from '../common/UploadWarning' +import {type ImageUrlBuilder} from '../types' import {type BaseImageInputProps, type BaseImageInputValue, type FileInfo} from './types' +import {usePreviewImageSource} from './usePreviewImageSource' const ASSET_FIELD_PATH = ['asset'] as const -function ImageInputAssetComponent( - props: { - elementProps: BaseImageInputProps['elementProps'] - handleClearUploadState: () => void - handleFilesOut: () => void - handleFilesOver: (hoveringFiles: FileInfo[]) => void - handleFileTargetFocus: (event: FocusEvent) => void - handleSelectFiles: (files: File[]) => void - hoveringFiles: FileInfo[] - inputProps: Omit - isStale: boolean - readOnly: boolean | undefined - renderAssetMenu(): JSX.Element | null - renderPreview: () => JSX.Element - renderUploadPlaceholder(): JSX.Element - renderUploadState(uploadState: UploadState): JSX.Element - tone: CardTone - value: BaseImageInputValue | undefined - }, - forwardedRef: React.ForwardedRef, -) { +function ImageInputAssetComponent(props: { + elementProps: BaseImageInputProps['elementProps'] + handleClearUploadState: () => void + handleFilesOut: () => void + handleFilesOver: (hoveringFiles: FileInfo[]) => void + handleFileTargetFocus: (event: FocusEvent) => void + handleSelectFiles: (files: File[]) => void + hoveringFiles: FileInfo[] + imageUrlBuilder: ImageUrlBuilder + inputProps: Omit + isStale: boolean + readOnly: boolean | undefined + renderAssetMenu(): JSX.Element | null + renderPreview: () => JSX.Element + renderUploadPlaceholder(): JSX.Element + renderUploadState(uploadState: UploadState): JSX.Element + tone: CardTone + value: BaseImageInputValue | undefined +}) { const { elementProps, handleClearUploadState, @@ -48,13 +48,15 @@ function ImageInputAssetComponent( renderUploadState, tone, value, + imageUrlBuilder, } = props const hasValueOrUpload = Boolean(value?._upload || value?.asset) const path = useMemo(() => inputProps.path.concat(ASSET_FIELD_PATH), [inputProps.path]) + const {customProperties} = usePreviewImageSource({value, imageUrlBuilder}) return ( - <> +
{isStale && ( @@ -79,7 +81,7 @@ function ImageInputAssetComponent( > {!value?.asset && renderUploadPlaceholder()} {!value?._upload && value?.asset && ( -
+
{renderPreview()} {renderAssetMenu()}
@@ -87,7 +89,7 @@ function ImageInputAssetComponent( )} - +
) } -export const ImageInputAsset = memo(forwardRef(ImageInputAssetComponent)) +export const ImageInputAsset = memo(ImageInputAssetComponent) diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputPreview.tsx b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputPreview.tsx index 99497c91700..ab092ae5d1a 100644 --- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputPreview.tsx +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputPreview.tsx @@ -1,49 +1,40 @@ -import {isImageSource} from '@sanity/asset-utils' import {type ImageSchemaType} from '@sanity/types' import {memo, useMemo} from 'react' -import {useDevicePixelRatio} from 'use-device-pixel-ratio' import {useTranslation} from '../../../../i18n' import {type UploaderResolver} from '../../../studio/uploads/types' import {type ImageUrlBuilder} from '../types' import {ImagePreview} from './ImagePreview' import {type BaseImageInputValue, type FileInfo} from './types' +import {usePreviewImageSource} from './usePreviewImageSource' export const ImageInputPreview = memo(function ImageInputPreviewComponent(props: { directUploads: boolean | undefined handleOpenDialog: () => void hoveringFiles: FileInfo[] imageUrlBuilder: ImageUrlBuilder - initialHeight: number | undefined readOnly: boolean | undefined resolveUploader: UploaderResolver schemaType: ImageSchemaType - value: BaseImageInputValue | undefined + value: BaseImageInputValue }) { const { directUploads, handleOpenDialog, hoveringFiles, imageUrlBuilder, - initialHeight, readOnly, resolveUploader, schemaType, value, } = props - const isValueImageSource = useMemo(() => isImageSource(value), [value]) - if (!value || !isValueImageSource) { - return null - } - return ( void hoveringFiles: FileInfo[] imageUrlBuilder: ImageUrlBuilder - initialHeight: number | undefined readOnly: boolean | undefined resolveUploader: UploaderResolver schemaType: ImageSchemaType @@ -68,7 +58,6 @@ function RenderImageInputPreview(props: { handleOpenDialog, hoveringFiles, imageUrlBuilder, - initialHeight, readOnly, resolveUploader, schemaType, @@ -84,20 +73,17 @@ function RenderImageInputPreview(props: { () => hoveringFiles.length - acceptedFiles.length, [acceptedFiles, hoveringFiles], ) - const dpr = useDevicePixelRatio() - const imageUrl = useMemo( - () => imageUrlBuilder.width(2000).fit('max').image(value).dpr(dpr).auto('format').url(), - [dpr, imageUrlBuilder, value], - ) + + const {url} = usePreviewImageSource({value, imageUrlBuilder}) + return ( 0} - initialHeight={initialHeight} isRejected={rejectedFilesCount > 0 || !directUploads} onDoubleClick={handleOpenDialog} readOnly={readOnly} - src={imageUrl} + src={url} /> ) } diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx b/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx index 7b54614372e..630ecaa030d 100644 --- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx @@ -1,38 +1,29 @@ import {Card, type CardTone, Flex, rgba, studioTheme} from '@sanity/ui' +import {useColorSchemeValue} from 'sanity' import {css, styled} from 'styled-components' -export const MAX_DEFAULT_HEIGHT = 30 - export const RatioBox = styled(Card)` position: relative; width: 100%; - overflow: hidden; - overflow: clip; min-height: 3.75rem; - max-height: 20rem; + max-height: min(calc(var(--image-height) * 1px), 30vh); + aspect-ratio: var(--image-width) / var(--image-height); - & > div[data-container] { - top: 0; - left: 0; + & img { + display: block; width: 100%; height: 100%; - display: flex !important; - align-items: center; - justify-content: center; - } - - & img { - max-width: 100%; - max-height: 100%; + object-fit: scale-down; + object-position: center; } ` export const Overlay = styled(Flex)<{ - $drag: boolean $tone: Exclude -}>(({$drag, $tone}) => { - const textColor = studioTheme.color.light[$tone].card.enabled.fg - const backgroundColor = rgba(studioTheme.color.light[$tone].card.enabled.bg, 0.8) +}>(({$tone}) => { + const colorScheme = useColorSchemeValue() + const textColor = studioTheme.color[colorScheme][$tone].card.enabled.fg + const backgroundColor = rgba(studioTheme.color[colorScheme][$tone].card.enabled.bg, 0.8) return css` position: absolute; @@ -40,9 +31,9 @@ export const Overlay = styled(Flex)<{ left: 0; right: 0; bottom: 0; - backdrop-filter: ${$drag ? 'blur(10px)' : ''}; + backdrop-filter: blur(10px); color: ${$tone ? textColor : ''}; - background-color: ${$drag ? backgroundColor : 'transparent'}; + background-color: ${backgroundColor}; ` }) diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.tsx b/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.tsx index 662b340704b..b1a5070453d 100644 --- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.tsx +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.tsx @@ -1,59 +1,25 @@ import {AccessDeniedIcon, ImageIcon, ReadOnlyIcon} from '@sanity/icons' -import {Box, Card, type CardTone, Heading, Text, useElementRect} from '@sanity/ui' +import {Box, type Card, type CardTone, Heading, Text} from '@sanity/ui' import {type ComponentProps, type ReactNode, useCallback, useEffect, useState} from 'react' import {LoadingBlock} from '../../../../components/loadingBlock' import {useTranslation} from '../../../../i18n' -import {FlexOverlay, MAX_DEFAULT_HEIGHT, Overlay, RatioBox} from './ImagePreview.styled' +import {FlexOverlay, Overlay, RatioBox} from './ImagePreview.styled' interface Props { alt: string drag: boolean - initialHeight: number | undefined isRejected: boolean readOnly?: boolean | null src: string } -/* - Used for setting the initial image height - specifically for images - that are small and so can take less space in the document -*/ -const getImageSize = (src: string): number[] => { - const imageUrlParams = new URLSearchParams(src.split('?')[1]) - const rect = imageUrlParams.get('rect') - - if (rect) { - return [rect.split(',')[2], rect.split(',')[3]].map(Number) - } - - return src.split('-')[1].split('.')[0].split('x').map(Number) -} - export function ImagePreview(props: ComponentProps & Props) { - const {drag, readOnly, isRejected, src, initialHeight, ...rest} = props + const {drag, readOnly, isRejected, src, ...rest} = props const [isLoaded, setLoaded] = useState(false) - const [rootElement, setRootElement] = useState(null) - const rootRect = useElementRect(rootElement) - const rootWidth = rootRect?.width || 0 const acceptTone = isRejected || readOnly ? 'critical' : 'primary' const tone = drag ? acceptTone : 'default' - const maxHeightToPx = (MAX_DEFAULT_HEIGHT * document.documentElement.clientHeight) / 100 // convert from vh to px, max height of the input - - const [imageWidth, imageHeight] = getImageSize(src) - - const imageRatio = imageWidth / imageHeight - - // is the image wider than root? if so calculate the resized height - const renderedImageHeight = imageWidth > rootWidth ? rootWidth / imageRatio : imageHeight - - /* - if the rendered image is smaller than the max height then it doesn't require a height set - otherwise, set the max height (to prevent a large image in the document) - */ - const rootHeight = renderedImageHeight < maxHeightToPx ? null : `${MAX_DEFAULT_HEIGHT}vh` - useEffect(() => { /* set for when the src is being switched when the image input already had a image src - meaning it already had an asset */ @@ -65,30 +31,20 @@ export function ImagePreview(props: ComponentProps & Props) { }, []) const {t} = useTranslation() - return ( - - - {!isLoaded && ( - } /> - )} - {props.alt} - + return ( + + {!isLoaded && } />} + {props.alt} {drag && ( @@ -132,15 +88,13 @@ function getHoverTextTranslationKey({ function OverlayComponent({ cardTone, - drag, content, }: { cardTone: Exclude - drag: boolean content: ReactNode }) { return ( - + {content} diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/usePreviewImageSource.ts b/packages/sanity/src/core/form/inputs/files/ImageInput/usePreviewImageSource.ts new file mode 100644 index 00000000000..97d532802ca --- /dev/null +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/usePreviewImageSource.ts @@ -0,0 +1,55 @@ +import {getImageDimensions, isImageSource, type SanityImageDimensions} from '@sanity/asset-utils' +import {type CSSProperties, useMemo} from 'react' +import {useDevicePixelRatio} from 'use-device-pixel-ratio' + +import {type ImageUrlBuilder} from '../types' +import {type BaseImageInputValue} from './types' + +export function usePreviewImageSource({ + value, + imageUrlBuilder, +}: { + value: Value + imageUrlBuilder: ImageUrlBuilder +}): { + url: Value extends undefined ? undefined : string + dimensions: SanityImageDimensions + customProperties: CSSProperties +} { + const dpr = useDevicePixelRatio() + + const url = useMemo( + () => + value && isImageSource(value) + ? imageUrlBuilder.width(2000).fit('max').image(value).dpr(dpr).auto('format').url() + : undefined, + [dpr, imageUrlBuilder, value], + ) as Value extends undefined ? undefined : string + + const dimensions = useMemo( + () => + url + ? getImageDimensions(url) + : { + width: 0, + height: 0, + aspectRatio: 0, + }, + [url], + ) + + const customProperties = useMemo( + () => + ({ + '--image-width': dimensions.width, + '--image-height': dimensions.height, + }) as CSSProperties, + [dimensions.width, dimensions.height], + ) + + return { + url, + dimensions, + customProperties, + } +} diff --git a/packages/sanity/src/core/form/inputs/files/common/UploadProgress.styled.tsx b/packages/sanity/src/core/form/inputs/files/common/UploadProgress.styled.tsx index 7859aa59e4e..8d650e0a8bd 100644 --- a/packages/sanity/src/core/form/inputs/files/common/UploadProgress.styled.tsx +++ b/packages/sanity/src/core/form/inputs/files/common/UploadProgress.styled.tsx @@ -1,12 +1,14 @@ -import {Card, Code, Flex, Stack} from '@sanity/ui' +import {Code, Flex, Stack} from '@sanity/ui' import {styled} from 'styled-components' -export const CardWrapper = styled(Card)` - min-height: 82px; +import {RatioBox} from '../ImageInput/ImagePreview.styled' + +export const CardWrapper = styled(RatioBox)` box-sizing: border-box; ` export const FlexWrapper = styled(Flex)` + box-sizing: border-box; text-overflow: ellipsis; overflow: hidden; overflow: clip; diff --git a/packages/sanity/src/core/form/inputs/files/common/UploadProgress.tsx b/packages/sanity/src/core/form/inputs/files/common/UploadProgress.tsx index 63394ae5407..72cd27922b1 100644 --- a/packages/sanity/src/core/form/inputs/files/common/UploadProgress.tsx +++ b/packages/sanity/src/core/form/inputs/files/common/UploadProgress.tsx @@ -12,11 +12,10 @@ type Props = { uploadState: UploadState onCancel?: () => void onStale?: () => void - height?: number } const elapsedMs = (date: string): number => new Date().getTime() - new Date(date).getTime() -export function UploadProgress({uploadState, onCancel, onStale, height}: Props) { +export function UploadProgress({uploadState, onCancel, onStale}: Props) { const filename = uploadState.file.name useEffect(() => { @@ -27,13 +26,15 @@ export function UploadProgress({uploadState, onCancel, onStale, height}: Props) const {t} = useTranslation() return ( - - + +