Skip to content

Commit

Permalink
fix(sanity): prevent layout shifts in image input
Browse files Browse the repository at this point in the history
  • Loading branch information
juice49 committed Sep 22, 2024
1 parent 4729e84 commit d567f7c
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 165 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,28 +67,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {

const uploadSubscription = useRef<null | Subscription>(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
Expand Down Expand Up @@ -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
Expand All @@ -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'])
Expand Down Expand Up @@ -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 (
<ImageInputPreview
directUploads={directUploads}
handleOpenDialog={handleOpenDialog}
hoveringFiles={hoveringFiles}
imageUrlBuilder={imageUrlBuilder}
// if there previously was a preview image, preserve the height to avoid jumps
initialHeight={previewElementRef.current.height}
readOnly={readOnly}
resolveUploader={resolveUploader}
schemaType={schemaType}
Expand Down Expand Up @@ -404,8 +377,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
uploadState={uploadState}
onCancel={isUploading ? handleCancelUpload : undefined}
onStale={handleStaleUpload}
// if there previously was a preview image, preserve the height to avoid jumps
height={previewElementRef.current.height}
/>
)
},
Expand All @@ -420,7 +391,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
// eslint-disable-next-line react/display-name
return (inputProps: Omit<InputProps, 'renderDefault'>) => (
<ImageInputAsset
ref={setPreviewElementHeight}
elementProps={elementProps}
handleClearUploadState={handleClearUploadState}
handleFilesOut={handleFilesOut}
Expand All @@ -437,6 +407,7 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
renderUploadState={renderUploadState}
tone={getFileTone()}
value={value}
imageUrlBuilder={imageUrlBuilder}
/>
)
}, [
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Element, Element>) => void
handleSelectFiles: (files: File[]) => void
hoveringFiles: FileInfo[]
inputProps: Omit<InputProps, 'renderDefault'>
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<HTMLDivElement>,
) {
function ImageInputAssetComponent(props: {
elementProps: BaseImageInputProps['elementProps']
handleClearUploadState: () => void
handleFilesOut: () => void
handleFilesOver: (hoveringFiles: FileInfo[]) => void
handleFileTargetFocus: (event: FocusEvent<Element, Element>) => void
handleSelectFiles: (files: File[]) => void
hoveringFiles: FileInfo[]
imageUrlBuilder: ImageUrlBuilder
inputProps: Omit<InputProps, 'renderDefault'>
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,
Expand All @@ -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 (
<>
<div style={customProperties}>
{isStale && (
<Box marginBottom={2}>
<UploadWarning onClearStale={handleClearUploadState} />
Expand All @@ -79,15 +81,15 @@ function ImageInputAssetComponent(
>
{!value?.asset && renderUploadPlaceholder()}
{!value?._upload && value?.asset && (
<div style={{position: 'relative'}} ref={forwardedRef}>
<div style={{position: 'relative'}}>
{renderPreview()}
{renderAssetMenu()}
</div>
)}
</FileTarget>
)}
</ChangeIndicator>
</>
</div>
)
}
export const ImageInputAsset = memo(forwardRef(ImageInputAssetComponent))
export const ImageInputAsset = memo(ImageInputAssetComponent)
Original file line number Diff line number Diff line change
@@ -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 (
<RenderImageInputPreview
directUploads={directUploads}
handleOpenDialog={handleOpenDialog}
hoveringFiles={hoveringFiles}
imageUrlBuilder={imageUrlBuilder}
initialHeight={initialHeight}
readOnly={readOnly}
resolveUploader={resolveUploader}
schemaType={schemaType}
Expand All @@ -57,7 +48,6 @@ function RenderImageInputPreview(props: {
handleOpenDialog: () => void
hoveringFiles: FileInfo[]
imageUrlBuilder: ImageUrlBuilder
initialHeight: number | undefined
readOnly: boolean | undefined
resolveUploader: UploaderResolver
schemaType: ImageSchemaType
Expand All @@ -68,7 +58,6 @@ function RenderImageInputPreview(props: {
handleOpenDialog,
hoveringFiles,
imageUrlBuilder,
initialHeight,
readOnly,
resolveUploader,
schemaType,
Expand All @@ -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 (
<ImagePreview
alt={t('inputs.image.preview-uploaded-image')}
drag={!value?._upload && hoveringFiles.length > 0}
initialHeight={initialHeight}
isRejected={rejectedFilesCount > 0 || !directUploads}
onDoubleClick={handleOpenDialog}
readOnly={readOnly}
src={imageUrl}
src={url}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
import {Card, type CardTone, Flex, rgba, studioTheme} from '@sanity/ui'
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), 20rem);
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;
}
`

Expand Down
Loading

0 comments on commit d567f7c

Please sign in to comment.