diff --git a/dev/design-studio/package.json b/dev/design-studio/package.json index 4a90fdd136d..32e3ab29391 100644 --- a/dev/design-studio/package.json +++ b/dev/design-studio/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@sanity/icons": "^3.0.0", - "@sanity/ui": "^2.3.3", + "@sanity/ui": "^2.3.5", "react": "^18.3.1", "react-dom": "^18.3.1", "sanity": "workspace:*", diff --git a/dev/embedded-studio/package.json b/dev/embedded-studio/package.json index 8b9aabaf4c0..4f32ec51fff 100644 --- a/dev/embedded-studio/package.json +++ b/dev/embedded-studio/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@sanity/ui": "^2.3.3", + "@sanity/ui": "^2.3.5", "react": "^18.3.1", "react-dom": "^18.3.1", "sanity": "workspace:*", diff --git a/dev/studio-e2e-testing/package.json b/dev/studio-e2e-testing/package.json index 326286dbeb2..cfd9245ef1c 100644 --- a/dev/studio-e2e-testing/package.json +++ b/dev/studio-e2e-testing/package.json @@ -16,7 +16,7 @@ "dependencies": { "@sanity/google-maps-input": "^4.0.0", "@sanity/icons": "^3.0.0", - "@sanity/ui": "^2.3.3", + "@sanity/ui": "^2.3.5", "@sanity/vision": "3.46.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/dev/test-next-studio/.depcheckignore.json b/dev/test-next-studio/.depcheckignore.json index 30836468c4c..b417fad3e91 100644 --- a/dev/test-next-studio/.depcheckignore.json +++ b/dev/test-next-studio/.depcheckignore.json @@ -4,6 +4,7 @@ "styled-components", "react", "react-dom", + "react-is", "sanity", "eslint-config-next", "sanity-test-studio", diff --git a/dev/test-next-studio/.depcheckrc.json b/dev/test-next-studio/.depcheckrc.json index 4a73450a11a..6834a373eee 100644 --- a/dev/test-next-studio/.depcheckrc.json +++ b/dev/test-next-studio/.depcheckrc.json @@ -1,3 +1,3 @@ { - "ignores": ["@sanity/vision", "typescript"] + "ignores": ["@sanity/vision", "typescript", "react-is"] } diff --git a/dev/test-next-studio/package.json b/dev/test-next-studio/package.json index 547802019b4..753ca3766d7 100644 --- a/dev/test-next-studio/package.json +++ b/dev/test-next-studio/package.json @@ -16,6 +16,7 @@ "next": "15.0.0-rc.0", "react": "19.0.0-rc-38e3b23483-20240529", "react-dom": "19.0.0-rc-38e3b23483-20240529", + "react-is": "19.0.0-rc-38e3b23483-20240529", "sanity": "workspace:*", "sanity-test-studio": "workspace:*", "styled-components": "^6.1.11", diff --git a/dev/test-studio/package.json b/dev/test-studio/package.json index 89a56748dba..2c6302babb9 100644 --- a/dev/test-studio/package.json +++ b/dev/test-studio/package.json @@ -39,7 +39,7 @@ "@sanity/react-loader": "^1.8.3", "@sanity/tsdoc": "1.0.72", "@sanity/types": "workspace:*", - "@sanity/ui": "^2.3.3", + "@sanity/ui": "^2.3.5", "@sanity/ui-workshop": "^1.0.0", "@sanity/util": "workspace:*", "@sanity/uuid": "^3.0.1", diff --git a/examples/ecommerce-studio/package.json b/examples/ecommerce-studio/package.json index cfedc348ae3..c5caedd865b 100644 --- a/examples/ecommerce-studio/package.json +++ b/examples/ecommerce-studio/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@sanity/cli": "3.46.1", - "@sanity/ui": "^2.3.1", + "@sanity/ui": "^2.3.5", "react": "^18.3.1", "react-barcode": "^1.4.1", "react-dom": "^18.3.1", diff --git a/package.json b/package.json index af55bb08fb6..e8a29d4281e 100644 --- a/package.json +++ b/package.json @@ -175,7 +175,8 @@ "peerDependencyRules": { "allowAny": [ "react", - "react-dom" + "react-dom", + "react-is" ] }, "overrides": { diff --git a/packages/@sanity/portable-text-editor/package.json b/packages/@sanity/portable-text-editor/package.json index 2f260054294..e88a23889da 100644 --- a/packages/@sanity/portable-text-editor/package.json +++ b/packages/@sanity/portable-text-editor/package.json @@ -74,7 +74,7 @@ "@portabletext/toolkit": "^2.0.15", "@repo/package.config": "workspace:*", "@sanity/diff-match-patch": "^3.1.1", - "@sanity/ui": "^2.3.1", + "@sanity/ui": "^2.3.5", "@testing-library/react": "^13.4.0", "@types/debug": "^4.1.5", "@types/express": "^4.17.21", diff --git a/packages/@sanity/vision/package.json b/packages/@sanity/vision/package.json index a74819f0b50..b508aacd019 100644 --- a/packages/@sanity/vision/package.json +++ b/packages/@sanity/vision/package.json @@ -63,7 +63,7 @@ "@rexxars/react-split-pane": "^0.1.93", "@sanity/color": "^3.0.0", "@sanity/icons": "^3.0.0", - "@sanity/ui": "^2.3.1", + "@sanity/ui": "^2.3.5", "@uiw/react-codemirror": "^4.11.4", "is-hotkey-esm": "^1.0.0", "json-2-csv": "^5.5.1", diff --git a/packages/sanity/package.json b/packages/sanity/package.json index f0a350a4467..ef78e82f7d6 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -166,7 +166,7 @@ "@sanity/schema": "3.46.1", "@sanity/telemetry": "^0.7.7", "@sanity/types": "3.46.1", - "@sanity/ui": "^2.3.1", + "@sanity/ui": "^2.3.5", "@sanity/util": "3.46.1", "@sanity/uuid": "^3.0.1", "@tanstack/react-table": "^8.16.0", diff --git a/packages/sanity/src/core/components/transitional/ImperativeToast.ts b/packages/sanity/src/core/components/transitional/ImperativeToast.ts index 8e6d1bda06d..34c7e4b76fb 100644 --- a/packages/sanity/src/core/components/transitional/ImperativeToast.ts +++ b/packages/sanity/src/core/components/transitional/ImperativeToast.ts @@ -11,7 +11,10 @@ export interface ToastParams { status?: 'error' | 'warning' | 'success' | 'info' } -/** @internal */ +/** + * @internal + * @deprecated -- Refactor the component so it can call `useToast` instead + */ export const ImperativeToast = forwardRef((_, ref) => { const {push} = useToast() 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 33fa9aaae7b..b7a6b264319 100644 --- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx @@ -1,231 +1,211 @@ -/* eslint-disable react/jsx-no-bind */ -/* eslint-disable import/no-unresolved,react/jsx-handler-names, react/display-name, react/no-this-in-sfc */ - import {isImageSource} from '@sanity/asset-utils' -import {type SanityClient} from '@sanity/client' -import {ChevronDownIcon, ImageIcon, SearchIcon} from '@sanity/icons' -import { - type AssetFromSource, - type AssetSource, - type Image as BaseImage, - type ImageAsset, - type ImageSchemaType, - type Path, - type UploadState, -} from '@sanity/types' -import {Box, Card, Menu, Stack, type ToastParams} from '@sanity/ui' -import {get, startCase} from 'lodash' -import {type FocusEvent, PureComponent, type ReactNode} from 'react' -import {type Observable, type Subscription} from 'rxjs' - -import { - Button, - Dialog, - MenuButton, - type MenuButtonProps, - MenuItem, -} from '../../../../../ui-components' -import {ChangeIndicator} from '../../../../changeIndicators' -import {ImperativeToast} from '../../../../components' -import {type FIXME} from '../../../../FIXME' -import {PresenceOverlay} from '../../../../presence' +import {type AssetFromSource, type AssetSource, type UploadState} from '@sanity/types' +import {Stack, useToast} from '@sanity/ui' +import {get} from 'lodash' +import {type FocusEvent, memo, type ReactNode, useCallback, useMemo, useRef, useState} from 'react' +import {type Subscription} from 'rxjs' + +import {useTranslation} from '../../../../i18n' import {FormInput} from '../../../components' import {MemberField, MemberFieldError, MemberFieldSet} from '../../../members' -import {type PatchEvent, setIfMissing, unset} from '../../../patch' +import {setIfMissing, unset} from '../../../patch' import {type FieldMember} from '../../../store' -import { - type ResolvedUploader, - type Uploader, - type UploaderResolver, - type UploadOptions, -} from '../../../studio/uploads/types' -import {type InputProps, type ObjectInputProps} from '../../../types' -import {WithReferencedAsset} from '../../../utils/WithReferencedAsset' -import {ActionsMenu} from '../common/ActionsMenu' -import {handleSelectAssetFromSource} from '../common/assetSource' -import {FileTarget} from '../common/styles' -import {UploadPlaceholder} from '../common/UploadPlaceholder' +import {type Uploader, type UploadOptions} from '../../../studio/uploads/types' +import {type InputProps} from '../../../types' +import {handleSelectAssetFromSource as _handleSelectAssetFromSource} from '../common/assetSource' import {UploadProgress} from '../common/UploadProgress' -import {UploadWarning} from '../common/UploadWarning' -import {ImageToolInput} from '../ImageToolInput' -import {type ImageUrlBuilder} from '../types' -import {ImageActionsMenu, ImageActionsMenuWaitPlaceholder} from './ImageActionsMenu' -import {ImagePreview} from './ImagePreview' +import {ImageInputAsset} from './ImageInputAsset' +import {ImageInputAssetMenu} from './ImageInputAssetMenu' +import {ImageInputAssetSource} from './ImageInputAssetSource' +import {ImageInputBrowser} from './ImageInputBrowser' +import {ImageInputHotspotInput} from './ImageInputHotspotInput' +import {ImageInputPreview} from './ImageInputPreview' +import {ImageInputUploadPlaceholder} from './ImageInputUploadPlaceholder' import {InvalidImageWarning} from './InvalidImageWarning' - -/** - * @hidden - * @beta */ -export interface BaseImageInputValue extends Partial { - _upload?: UploadState -} - -/** - * @hidden - * @beta */ -export interface BaseImageInputProps - extends ObjectInputProps { - assetSources: AssetSource[] - directUploads?: boolean - imageUrlBuilder: ImageUrlBuilder - observeAsset: (documentId: string) => Observable - resolveUploader: UploaderResolver - client: SanityClient - t: (key: string, values?: Record) => string -} - -const getDevicePixelRatio = () => { - if (typeof window === 'undefined' || !window.devicePixelRatio) { - return 1 - } - return Math.round(Math.max(1, window.devicePixelRatio)) -} - -type FileInfo = { - type: string // mime type - kind: string // 'file' or 'string' -} - -interface BaseImageInputState { - isUploading: boolean - selectedAssetSource: AssetSource | null - // Metadata about files currently over the drop area - hoveringFiles: FileInfo[] - isStale: boolean - hotspotButtonElement: HTMLButtonElement | null - menuButtonElement: HTMLButtonElement | null - isMenuOpen: boolean -} - -function passThrough({children}: {children?: ReactNode}) { - return children -} - -const ASSET_FIELD_PATH = ['asset'] - -const ASSET_IMAGE_MENU_POPOVER: MenuButtonProps['popover'] = {portal: true} - -/** @internal */ -export class BaseImageInput extends PureComponent { - _previewElement: HTMLDivElement | null = null - _assetPath: Path - uploadSubscription: null | Subscription = null - - state: BaseImageInputState = { - isUploading: false, - selectedAssetSource: null, - hoveringFiles: [], - isStale: false, - hotspotButtonElement: null, - menuButtonElement: null, - isMenuOpen: false, - } - - constructor(props: BaseImageInputProps) { - super(props) - this._assetPath = props.path.concat(ASSET_FIELD_PATH) - } - - toast: {push: (params: ToastParams) => void} | null = null - - setPreviewElement = (el: HTMLDivElement | null) => { - this._previewElement = el - } - - setHotspotButtonElement = (el: HTMLButtonElement | null) => { - this.setState({hotspotButtonElement: el}) - } - +import {type BaseImageInputProps, type BaseImageInputValue, type FileInfo} from './types' + +export {BaseImageInputProps, BaseImageInputValue} + +function BaseImageInputComponent(props: BaseImageInputProps): JSX.Element { + const { + assetSources, + client, + directUploads, + elementProps, + focusPath, + id, + imageUrlBuilder, + members, + observeAsset, + onChange, + onPathFocus, + path, + readOnly, + renderAnnotation, + renderBlock, + renderField, + renderInlineBlock, + renderInput, + renderItem, + renderPreview: renderPreviewProp, + resolveUploader, + schemaType, + value, + } = props + const {push} = useToast() + const {t} = useTranslation() + + const [selectedAssetSource, setSelectedAssetSource] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [hoveringFiles, setHoveringFiles] = useState([]) + const [isStale, setIsStale] = useState(false) + const [hotspotButtonElement, setHotspotButtonElement] = useState(null) // Get the menu button element in `ImageActionsMenu` so that focus can be restored to // it when closing the dialog (see `handleAssetSourceClosed`) - setMenuButtonElement = (el: HTMLButtonElement | null) => { - this.setState({menuButtonElement: el}) - } - - isImageToolEnabled() { - return get(this.props.schemaType, 'options.hotspot') === true - } + const [menuButtonElement, setMenuButtonElement] = useState(null) + const [isMenuOpen, setMenuOpen] = useState(false) + + 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 - clearUploadStatus() { - if (this.props.value?._upload) { - this.props.onChange(unset(['_upload'])) + if (hoveringFiles.length > 0) { + if (rejectedFilesCount > 0 || !directUploads) { + return 'critical' + } } - } - cancelUpload() { - if (this.uploadSubscription) { - this.uploadSubscription.unsubscribe() - this.clearUploadStatus() + if (!value?._upload && !readOnly && hoveringFiles.length > 0) { + return 'primary' } - } - getUploadOptions = (file: File): ResolvedUploader[] => { - const {schemaType, resolveUploader} = this.props - const uploader = resolveUploader && resolveUploader(schemaType, file) - return uploader ? [{type: schemaType, uploader}] : [] - } + if (readOnly) { + return 'transparent' + } - uploadFirstAccepted(files: File[]) { - const {schemaType, resolveUploader} = this.props + return value?._upload && value?.asset ? 'transparent' : 'default' + }, [ + directUploads, + hoveringFiles, + readOnly, + resolveUploader, + schemaType, + value?._upload, + value?.asset, + ]) + const isImageToolEnabled = useCallback( + () => get(schemaType, 'options.hotspot') === true, + [schemaType], + ) + const valueIsArrayElement = useCallback(() => { + const parentPathSegment = path.slice(-1)[0] - const match = files - .map((file) => ({file, uploader: resolveUploader(schemaType, file)})) - .find((result) => result.uploader) + // String path segment mean an object path, while a number or a + // keyed segment means we're a direct child of an array + return typeof parentPathSegment !== 'string' + }, [path]) - if (match) { - this.uploadWith(match.uploader!, match.file) + const clearUploadStatus = useCallback(() => { + if (value?._upload) { + onChange(unset(['_upload'])) } - - this.setState({isMenuOpen: false}) - } - - uploadWith = (uploader: Uploader, file: File, assetDocumentProps: UploadOptions = {}) => { - const {schemaType, onChange, client, t} = this.props - const {label, title, description, creditLine, source} = assetDocumentProps - const options = { - metadata: get(schemaType, 'options.metadata'), - storeOriginalFilename: get(schemaType, 'options.storeOriginalFilename'), - label, - title, - description, - creditLine, - source, + }, [onChange, value?._upload]) + const cancelUpload = useCallback(() => { + if (uploadSubscription.current) { + uploadSubscription.current.unsubscribe() + clearUploadStatus() } + }, [clearUploadStatus]) + const uploadWith = useCallback( + (uploader: Uploader, file: File, assetDocumentProps: UploadOptions = {}) => { + const {label, title, description, creditLine, source} = assetDocumentProps + const options = { + metadata: get(schemaType, 'options.metadata'), + storeOriginalFilename: get(schemaType, 'options.storeOriginalFilename'), + label, + title, + description, + creditLine, + source, + } - this.cancelUpload() - this.setState({isUploading: true}) - onChange(setIfMissing({_type: schemaType.name})) - this.uploadSubscription = uploader.upload(client, file, schemaType, options).subscribe({ - next: (uploadEvent) => { - if (uploadEvent.patches) { - onChange(uploadEvent.patches) - } - }, - error: (err) => { - // eslint-disable-next-line no-console - console.error(err) - this.toast?.push({ - status: 'error', - description: t('inputs.image.upload-error.description'), - title: t('inputs.image.upload-error.title'), - }) - - this.clearUploadStatus() - }, - complete: () => { - onChange([unset(['hotspot']), unset(['crop'])]) - this.setState({isUploading: false}) - // this.toast.push({ - // status: 'success', - // title: 'Upload completed', - // }) - }, - }) - } - - handleRemoveButtonClick = () => { - const {value} = this.props - + cancelUpload() + setIsUploading(true) + onChange(setIfMissing({_type: schemaType.name})) + uploadSubscription.current = uploader.upload(client, file, schemaType, options).subscribe({ + next: (uploadEvent) => { + if (uploadEvent.patches) { + onChange(uploadEvent.patches) + } + }, + error: (err) => { + // eslint-disable-next-line no-console + console.error(err) + push({ + status: 'error', + description: t('inputs.image.upload-error.description'), + title: t('inputs.image.upload-error.title'), + }) + + clearUploadStatus() + }, + complete: () => { + onChange([unset(['hotspot']), unset(['crop'])]) + setIsUploading(false) + // push({ + // status: 'success', + // title: 'Upload completed', + // }) + }, + }) + }, + [cancelUpload, clearUploadStatus, client, onChange, push, schemaType, t], + ) + const uploadFirstAccepted = useCallback( + (files: File[]) => { + const match = files + .map((file) => ({file, uploader: resolveUploader(schemaType, file)})) + .find((result) => result.uploader) + + if (match) { + uploadWith(match.uploader!, match.file) + } + setMenuOpen(false) + }, + [resolveUploader, schemaType, uploadWith], + ) + + 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 // _type and _key are "meta"-properties and are not significant unless // other properties are present. Thus, we want to remove the entire @@ -243,626 +223,345 @@ export class BaseImageInput extends PureComponent ['crop', 'hotspot', '_upload'].includes(key))) .map((key) => unset([key])) - this.props.onChange(isEmpty && !this.valueIsArrayElement() ? unset() : removeKeys) - } - - handleFieldChange = (event: PatchEvent) => { - const {onChange, schemaType} = this.props - - // When editing a metadata field for an image (eg `altText`), and no asset - // is currently selected, we want to unset the entire image field if the - // field we are currently editing goes blank and gets unset. - // - // For instance: - // An image field with an `altText` and a `title` subfield, where the image - // `asset` and the `title` field is empty, and we are erasing the `alt` field. - // We do _not_ however want to clear the field if any content is present in - // the other fields - but we do not consider `crop` and `hotspot`. - // - // Also, we don't want to use this logic for array items, since the parent will - // take care of it when closing the array dialog - if (!this.valueIsArrayElement() && this.eventIsUnsettingLastFilledField(event)) { - onChange(unset()) - return - } + onChange(isEmpty && !valueIsArrayElement() ? unset() : removeKeys) - onChange( - event.prepend( - setIfMissing({ - _type: schemaType.name, - }), - ).patches, - ) - } - - eventIsUnsettingLastFilledField = (event: PatchEvent): boolean => { - const patch = event.patches[0] - if (event.patches.length !== 1 || patch.type !== 'unset') { - return false - } - - const allKeys = Object.keys(this.props.value || {}) - const remainingKeys = allKeys.filter( - (key) => !['_type', '_key', 'crop', 'hotspot'].includes(key), - ) - - const isEmpty = - event.patches[0].path.length === 1 && - remainingKeys.length === 1 && - remainingKeys[0] === event.patches[0].path[0] - - return isEmpty - } - - valueIsArrayElement = () => { - const {path} = this.props - const parentPathSegment = path.slice(-1)[0] - - // String path segment mean an object path, while a number or a - // keyed segment means we're a direct child of an array - return typeof parentPathSegment !== 'string' - } - - handleOpenDialog = () => { - this.props.onPathFocus(['hotspot']) - } - - handleCloseDialog = () => { - this.props.onPathFocus([]) + previewElementRef.current.el = null + previewElementRef.current.height = 0 + }, [onChange, value, valueIsArrayElement]) + const handleOpenDialog = useCallback(() => { + onPathFocus(['hotspot']) + }, [onPathFocus]) + const handleCloseDialog = useCallback(() => { + onPathFocus([]) // Set focus on hotspot button in `ImageActionsMenu` when closing the dialog - this.state.hotspotButtonElement?.focus() - } - - handleSelectAssetFromSource = (assetFromSource: AssetFromSource[]) => { - const {onChange, schemaType, resolveUploader} = this.props - handleSelectAssetFromSource({ - assetFromSource, - onChange, - type: schemaType, - resolveUploader, - uploadWith: this.uploadWith, - isImage: true, - }) - - this.setState({selectedAssetSource: null}) - } - - handleFileTargetFocus = (event: FocusEvent) => { - // We want to handle focus when the file target element *itself* receives - // focus, not when an interactive child element receives focus. Since React has decided - // to let focus bubble, so this workaround is needed - // Background: https://github.com/facebook/react/issues/6410#issuecomment-671915381 - if ( - event.currentTarget === event.target && - event.currentTarget === this.props.elementProps.ref?.current - ) { - this.props.elementProps.onFocus(event) - } - } - - handleFilesOver = (hoveringFiles: FileInfo[]) => { - this.setState({ - hoveringFiles: hoveringFiles.filter((file) => file.kind !== 'string'), - }) - } - handleFilesOut = () => { - this.setState({ - hoveringFiles: [], - }) - } - - handleCancelUpload = () => { - this.cancelUpload() - } - - handleClearUploadState = () => { - this.setState({isStale: false}) - this.clearUploadStatus() - } - - handleStaleUpload = () => { - this.setState({isStale: true}) - } - - handleClearField = () => { - this.props.onChange([unset(['asset']), unset(['crop']), unset(['hotspot'])]) - } - - handleSelectFiles = (files: File[]) => { - const {directUploads, readOnly} = this.props - const {hoveringFiles} = this.state - if (directUploads && !readOnly) { - this.uploadFirstAccepted(files) - } else if (hoveringFiles.length > 0) { - this.handleFilesOut() - } - } - - handleSelectImageFromAssetSource = (source: AssetSource) => { - this.setState({selectedAssetSource: source}) - } + hotspotButtonElement?.focus() + }, [hotspotButtonElement, onPathFocus]) + const handleSelectAssetFromSource = useCallback( + (assetFromSource: AssetFromSource[]) => { + _handleSelectAssetFromSource({ + assetFromSource, + onChange, + type: schemaType, + resolveUploader, + uploadWith, + isImage: true, + }) - handleAssetSourceClosed = () => { - this.setState({selectedAssetSource: null}) + setSelectedAssetSource(null) + }, + [onChange, resolveUploader, schemaType, uploadWith], + ) + const handleFileTargetFocus = useCallback( + (event: FocusEvent) => { + // We want to handle focus when the file target element *itself* receives + // focus, not when an interactive child element receives focus. Since React has decided + // to let focus bubble, so this workaround is needed + // Background: https://github.com/facebook/react/issues/6410#issuecomment-671915381 + if ( + event.currentTarget === event.target && + event.currentTarget === elementProps.ref?.current + ) { + elementProps.onFocus(event) + } + }, + [elementProps], + ) + const handleFilesOver = useCallback((nextHoveringFiles: FileInfo[]) => { + setHoveringFiles(nextHoveringFiles.filter((file) => file.kind !== 'string')) + }, []) + const handleFilesOut = useCallback(() => { + setHoveringFiles([]) + }, []) + const handleCancelUpload = useCallback(() => { + cancelUpload() + }, [cancelUpload]) + const handleClearUploadState = useCallback(() => { + setIsStale(false) + clearUploadStatus() + }, [clearUploadStatus]) + const handleStaleUpload = useCallback(() => { + setIsStale(true) + }, []) + const handleSelectFiles = useCallback( + (files: File[]) => { + if (directUploads && !readOnly) { + uploadFirstAccepted(files) + } else if (hoveringFiles.length > 0) { + handleFilesOut() + } + }, + [directUploads, handleFilesOut, hoveringFiles.length, readOnly, uploadFirstAccepted], + ) + const handleSelectImageFromAssetSource = useCallback((source: AssetSource) => { + setSelectedAssetSource(source) + }, []) + const handleAssetSourceClosed = useCallback(() => { + setSelectedAssetSource(null) // Set focus on menu button in `ImageActionsMenu` when closing the dialog - this.state.menuButtonElement?.focus() - } - - renderHotspotInput = (hotspotInputProps: Omit) => { - const {value, changed, id, imageUrlBuilder, t} = this.props - - const withImageTool = this.isImageToolEnabled() && value && value.asset - - return ( - - - - {withImageTool && value?.asset && ( - - )} - - - - ) - } - - renderPreview = () => { - const {value, schemaType, readOnly, directUploads, imageUrlBuilder, t, resolveUploader} = - this.props - - if (!value || !isImageSource(value)) { - return null - } - - const {hoveringFiles} = this.state - - const acceptedFiles = hoveringFiles.filter((file) => resolveUploader(schemaType, file)) - - const rejectedFilesCount = hoveringFiles.length - acceptedFiles.length - const imageUrl = imageUrlBuilder - .width(2000) - .fit('max') - .image(value) - .dpr(getDevicePixelRatio()) - .auto('format') - .url() + menuButtonElement?.focus() + }, [menuButtonElement]) + const renderPreview = useCallback(() => { return ( - 0} - isRejected={rejectedFilesCount > 0 || !directUploads} + ) - } - - renderAssetMenu() { - const { - value, - assetSources, - schemaType, - readOnly, - directUploads, - imageUrlBuilder, - observeAsset, - t, - } = this.props - - const asset = value?.asset - if (!asset) { - return null - } - - const accept = get(schemaType, 'options.accept', 'image/*') - - const showAdvancedEditButton = value && asset && this.isImageToolEnabled() - - let browseMenuItem: ReactNode = - assetSources && assetSources.length === 0 ? null : ( - { - this.setState({isMenuOpen: false}) - this.handleSelectImageFromAssetSource(assetSources[0]) - }} - disabled={readOnly} - data-testid="file-input-browse-button" - /> - ) - if (assetSources && assetSources.length > 1) { - browseMenuItem = assetSources.map((assetSource) => { - return ( - { - this.setState({isMenuOpen: false}) - this.handleSelectImageFromAssetSource(assetSource) - }} - icon={assetSource.icon || ImageIcon} - data-testid={`file-input-browse-button-${assetSource.name}`} - disabled={readOnly} - /> - ) - }) - } - + }, [ + directUploads, + handleOpenDialog, + hoveringFiles, + imageUrlBuilder, + readOnly, + resolveUploader, + schemaType, + value, + ]) + const renderAssetMenu = useCallback(() => { return ( - } - > - {({_id, originalFilename, extension}) => { - let copyUrl: string | undefined - let downloadUrl: string | undefined - - if (isImageSource(value)) { - const filename = originalFilename || `download.${extension}` - downloadUrl = imageUrlBuilder.image(_id).forceDownload(filename).url() - copyUrl = imageUrlBuilder.image(_id).url() - } - - return ( - this.setState({isMenuOpen: isOpen})} - setHotspotButtonElement={this.setHotspotButtonElement} - setMenuButtonElement={this.setMenuButtonElement} - showEdit={showAdvancedEditButton} - > - - - ) - }} - - ) - } - - renderBrowser() { - const {assetSources, readOnly, directUploads, id, t} = this.props - - if (assetSources && assetSources.length === 0) return null - - if (assetSources && assetSources.length > 1 && !readOnly && directUploads) { - return ( - - } - menu={ - - {assetSources.map((assetSource) => { - return ( - { - this.setState({isMenuOpen: false}) - this.handleSelectImageFromAssetSource(assetSource) - }} - icon={assetSource.icon || ImageIcon} - disabled={readOnly} - data-testid={`file-input-browse-button-${assetSource.name}`} - /> - ) - })} - - } - popover={ASSET_IMAGE_MENU_POPOVER} - /> - ) - } - - return ( -