Skip to content

Commit 6b8a76f

Browse files
committed
fix(sanity): prevent layout shifts in image input
1 parent 306a671 commit 6b8a76f

File tree

8 files changed

+131
-165
lines changed

8 files changed

+131
-165
lines changed

packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx

+6-35
Original file line numberDiff line numberDiff line change
@@ -67,28 +67,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
6767

6868
const uploadSubscription = useRef<null | Subscription>(null)
6969

70-
/**
71-
* The upload progress state wants to use the same height as any previous image
72-
* to avoid layout shifts and jumps
73-
*/
74-
const previewElementRef = useRef<{el: HTMLDivElement | null; height: number}>({
75-
el: null,
76-
height: 0,
77-
})
78-
const setPreviewElementHeight = useCallback((node: HTMLDivElement | null) => {
79-
if (node) {
80-
previewElementRef.current.el = node
81-
previewElementRef.current.height = node.offsetHeight
82-
} else {
83-
/**
84-
* If `node` is `null` then it means the `FileTarget` in `ImageInputAsset` is being unmounted and we want to
85-
* capture its height before it's removed from the DOM.
86-
*/
87-
88-
previewElementRef.current.height = previewElementRef.current.el?.offsetHeight || 0
89-
previewElementRef.current.el = null
90-
}
91-
}, [])
9270
const getFileTone = useCallback(() => {
9371
const acceptedFiles = hoveringFiles.filter((file) => resolveUploader(schemaType, file))
9472
const rejectedFilesCount = hoveringFiles.length - acceptedFiles.length
@@ -201,9 +179,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
201179

202180
const handleClearField = useCallback(() => {
203181
onChange([unset(['asset']), unset(['crop']), unset(['hotspot'])])
204-
205-
previewElementRef.current.el = null
206-
previewElementRef.current.height = 0
207182
}, [onChange])
208183
const handleRemoveButtonClick = useCallback(() => {
209184
// When removing the image, we should also remove any crop and hotspot
@@ -224,9 +199,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
224199
.map((key) => unset([key]))
225200

226201
onChange(isEmpty && !valueIsArrayElement() ? unset() : removeKeys)
227-
228-
previewElementRef.current.el = null
229-
previewElementRef.current.height = 0
230202
}, [onChange, value, valueIsArrayElement])
231203
const handleOpenDialog = useCallback(() => {
232204
onPathFocus(['hotspot'])
@@ -303,15 +275,16 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
303275
menuButtonElement?.focus()
304276
}, [menuButtonElement])
305277

306-
const renderPreview = useCallback(() => {
278+
const renderPreview = useCallback<() => JSX.Element>(() => {
279+
if (!value) {
280+
return <></>
281+
}
307282
return (
308283
<ImageInputPreview
309284
directUploads={directUploads}
310285
handleOpenDialog={handleOpenDialog}
311286
hoveringFiles={hoveringFiles}
312287
imageUrlBuilder={imageUrlBuilder}
313-
// if there previously was a preview image, preserve the height to avoid jumps
314-
initialHeight={previewElementRef.current.height}
315288
readOnly={readOnly}
316289
resolveUploader={resolveUploader}
317290
schemaType={schemaType}
@@ -404,8 +377,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
404377
uploadState={uploadState}
405378
onCancel={isUploading ? handleCancelUpload : undefined}
406379
onStale={handleStaleUpload}
407-
// if there previously was a preview image, preserve the height to avoid jumps
408-
height={previewElementRef.current.height}
409380
/>
410381
)
411382
},
@@ -420,7 +391,6 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
420391
// eslint-disable-next-line react/display-name
421392
return (inputProps: Omit<InputProps, 'renderDefault'>) => (
422393
<ImageInputAsset
423-
ref={setPreviewElementHeight}
424394
elementProps={elementProps}
425395
handleClearUploadState={handleClearUploadState}
426396
handleFilesOut={handleFilesOut}
@@ -437,6 +407,7 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
437407
renderUploadState={renderUploadState}
438408
tone={getFileTone()}
439409
value={value}
410+
imageUrlBuilder={imageUrlBuilder}
440411
/>
441412
)
442413
}, [
@@ -449,13 +420,13 @@ function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element {
449420
handleFilesOver,
450421
handleSelectFiles,
451422
hoveringFiles,
423+
imageUrlBuilder,
452424
isStale,
453425
readOnly,
454426
renderAssetMenu,
455427
renderPreview,
456428
renderUploadPlaceholder,
457429
renderUploadState,
458-
setPreviewElementHeight,
459430
value,
460431
])
461432
const renderHotspotInput = useCallback(

packages/sanity/src/core/form/inputs/files/ImageInput/ImageInputAsset.tsx

+28-26
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,36 @@
11
import {type UploadState} from '@sanity/types'
22
import {Box, type CardTone} from '@sanity/ui'
3-
import {type FocusEvent, forwardRef, memo, useMemo} from 'react'
3+
import {type FocusEvent, memo, useMemo} from 'react'
44

55
import {ChangeIndicator} from '../../../../changeIndicators'
66
import {type InputProps} from '../../../types'
77
import {FileTarget} from '../common/styles'
88
import {UploadWarning} from '../common/UploadWarning'
9+
import {type ImageUrlBuilder} from '../types'
910
import {type BaseImageInputProps, type BaseImageInputValue, type FileInfo} from './types'
11+
import {usePreviewImageSource} from './usePreviewImageSource'
1012

1113
const ASSET_FIELD_PATH = ['asset'] as const
1214

13-
function ImageInputAssetComponent(
14-
props: {
15-
elementProps: BaseImageInputProps['elementProps']
16-
handleClearUploadState: () => void
17-
handleFilesOut: () => void
18-
handleFilesOver: (hoveringFiles: FileInfo[]) => void
19-
handleFileTargetFocus: (event: FocusEvent<Element, Element>) => void
20-
handleSelectFiles: (files: File[]) => void
21-
hoveringFiles: FileInfo[]
22-
inputProps: Omit<InputProps, 'renderDefault'>
23-
isStale: boolean
24-
readOnly: boolean | undefined
25-
renderAssetMenu(): JSX.Element | null
26-
renderPreview: () => JSX.Element
27-
renderUploadPlaceholder(): JSX.Element
28-
renderUploadState(uploadState: UploadState): JSX.Element
29-
tone: CardTone
30-
value: BaseImageInputValue | undefined
31-
},
32-
forwardedRef: React.ForwardedRef<HTMLDivElement>,
33-
) {
15+
function ImageInputAssetComponent(props: {
16+
elementProps: BaseImageInputProps['elementProps']
17+
handleClearUploadState: () => void
18+
handleFilesOut: () => void
19+
handleFilesOver: (hoveringFiles: FileInfo[]) => void
20+
handleFileTargetFocus: (event: FocusEvent<Element, Element>) => void
21+
handleSelectFiles: (files: File[]) => void
22+
hoveringFiles: FileInfo[]
23+
imageUrlBuilder: ImageUrlBuilder
24+
inputProps: Omit<InputProps, 'renderDefault'>
25+
isStale: boolean
26+
readOnly: boolean | undefined
27+
renderAssetMenu(): JSX.Element | null
28+
renderPreview: () => JSX.Element
29+
renderUploadPlaceholder(): JSX.Element
30+
renderUploadState(uploadState: UploadState): JSX.Element
31+
tone: CardTone
32+
value: BaseImageInputValue | undefined
33+
}) {
3434
const {
3535
elementProps,
3636
handleClearUploadState,
@@ -48,13 +48,15 @@ function ImageInputAssetComponent(
4848
renderUploadState,
4949
tone,
5050
value,
51+
imageUrlBuilder,
5152
} = props
5253

5354
const hasValueOrUpload = Boolean(value?._upload || value?.asset)
5455
const path = useMemo(() => inputProps.path.concat(ASSET_FIELD_PATH), [inputProps.path])
56+
const {customProperties} = usePreviewImageSource({value, imageUrlBuilder})
5557

5658
return (
57-
<>
59+
<div style={customProperties}>
5860
{isStale && (
5961
<Box marginBottom={2}>
6062
<UploadWarning onClearStale={handleClearUploadState} />
@@ -79,15 +81,15 @@ function ImageInputAssetComponent(
7981
>
8082
{!value?.asset && renderUploadPlaceholder()}
8183
{!value?._upload && value?.asset && (
82-
<div style={{position: 'relative'}} ref={forwardedRef}>
84+
<div style={{position: 'relative'}}>
8385
{renderPreview()}
8486
{renderAssetMenu()}
8587
</div>
8688
)}
8789
</FileTarget>
8890
)}
8991
</ChangeIndicator>
90-
</>
92+
</div>
9193
)
9294
}
93-
export const ImageInputAsset = memo(forwardRef(ImageInputAssetComponent))
95+
export const ImageInputAsset = memo(ImageInputAssetComponent)
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,40 @@
1-
import {isImageSource} from '@sanity/asset-utils'
21
import {type ImageSchemaType} from '@sanity/types'
32
import {memo, useMemo} from 'react'
4-
import {useDevicePixelRatio} from 'use-device-pixel-ratio'
53

64
import {useTranslation} from '../../../../i18n'
75
import {type UploaderResolver} from '../../../studio/uploads/types'
86
import {type ImageUrlBuilder} from '../types'
97
import {ImagePreview} from './ImagePreview'
108
import {type BaseImageInputValue, type FileInfo} from './types'
9+
import {usePreviewImageSource} from './usePreviewImageSource'
1110

1211
export const ImageInputPreview = memo(function ImageInputPreviewComponent(props: {
1312
directUploads: boolean | undefined
1413
handleOpenDialog: () => void
1514
hoveringFiles: FileInfo[]
1615
imageUrlBuilder: ImageUrlBuilder
17-
initialHeight: number | undefined
1816
readOnly: boolean | undefined
1917
resolveUploader: UploaderResolver
2018
schemaType: ImageSchemaType
21-
value: BaseImageInputValue | undefined
19+
value: BaseImageInputValue
2220
}) {
2321
const {
2422
directUploads,
2523
handleOpenDialog,
2624
hoveringFiles,
2725
imageUrlBuilder,
28-
initialHeight,
2926
readOnly,
3027
resolveUploader,
3128
schemaType,
3229
value,
3330
} = props
3431

35-
const isValueImageSource = useMemo(() => isImageSource(value), [value])
36-
if (!value || !isValueImageSource) {
37-
return null
38-
}
39-
4032
return (
4133
<RenderImageInputPreview
4234
directUploads={directUploads}
4335
handleOpenDialog={handleOpenDialog}
4436
hoveringFiles={hoveringFiles}
4537
imageUrlBuilder={imageUrlBuilder}
46-
initialHeight={initialHeight}
4738
readOnly={readOnly}
4839
resolveUploader={resolveUploader}
4940
schemaType={schemaType}
@@ -57,7 +48,6 @@ function RenderImageInputPreview(props: {
5748
handleOpenDialog: () => void
5849
hoveringFiles: FileInfo[]
5950
imageUrlBuilder: ImageUrlBuilder
60-
initialHeight: number | undefined
6151
readOnly: boolean | undefined
6252
resolveUploader: UploaderResolver
6353
schemaType: ImageSchemaType
@@ -68,7 +58,6 @@ function RenderImageInputPreview(props: {
6858
handleOpenDialog,
6959
hoveringFiles,
7060
imageUrlBuilder,
71-
initialHeight,
7261
readOnly,
7362
resolveUploader,
7463
schemaType,
@@ -84,20 +73,17 @@ function RenderImageInputPreview(props: {
8473
() => hoveringFiles.length - acceptedFiles.length,
8574
[acceptedFiles, hoveringFiles],
8675
)
87-
const dpr = useDevicePixelRatio()
88-
const imageUrl = useMemo(
89-
() => imageUrlBuilder.width(2000).fit('max').image(value).dpr(dpr).auto('format').url(),
90-
[dpr, imageUrlBuilder, value],
91-
)
76+
77+
const {url} = usePreviewImageSource({value, imageUrlBuilder})
78+
9279
return (
9380
<ImagePreview
9481
alt={t('inputs.image.preview-uploaded-image')}
9582
drag={!value?._upload && hoveringFiles.length > 0}
96-
initialHeight={initialHeight}
9783
isRejected={rejectedFilesCount > 0 || !directUploads}
9884
onDoubleClick={handleOpenDialog}
9985
readOnly={readOnly}
100-
src={imageUrl}
86+
src={url}
10187
/>
10288
)
10389
}

packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx

+6-16
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
11
import {Card, type CardTone, Flex, rgba, studioTheme} from '@sanity/ui'
22
import {css, styled} from 'styled-components'
33

4-
export const MAX_DEFAULT_HEIGHT = 30
5-
64
export const RatioBox = styled(Card)`
75
position: relative;
86
width: 100%;
9-
overflow: hidden;
10-
overflow: clip;
117
min-height: 3.75rem;
12-
max-height: 20rem;
8+
max-height: min(calc(var(--image-height) * 1px), 20rem);
9+
aspect-ratio: var(--image-width) / var(--image-height);
1310
14-
& > div[data-container] {
15-
top: 0;
16-
left: 0;
11+
& img {
12+
display: block;
1713
width: 100%;
1814
height: 100%;
19-
display: flex !important;
20-
align-items: center;
21-
justify-content: center;
22-
}
23-
24-
& img {
25-
max-width: 100%;
26-
max-height: 100%;
15+
object-fit: scale-down;
16+
object-position: center;
2717
}
2818
`
2919

0 commit comments

Comments
 (0)