Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(sanity): prevent layout shifts in image input #7535

Merged
merged 4 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>(() => {
juice49 marked this conversation as resolved.
Show resolved Hide resolved
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,48 +1,39 @@
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<CardTone, 'inherit'>
}>(({$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;
top: 0;
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};
`
})

Expand Down
Loading
Loading