'use client'; import { useRef } from 'react'; import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { mergeStyle } from '../../helpers/mergeStyle'; import { useExternRef } from '../../hooks/useExternRef'; import { useMergeProps } from '../../hooks/useMergeProps'; import { minOr } from '../../lib/comparing'; import { defineComponentDisplayNames } from '../../lib/react/defineComponentDisplayNames'; import { getFetchPriorityProp } from '../../lib/utils'; import { warnOnce } from '../../lib/warnOnce'; import type { AnchorHTMLAttributesOnly, HasDataAttribute, HasRootRef, LiteralUnion, } from '../../types'; import { Clickable } from '../Clickable/Clickable'; import { ImageBaseBadge, type ImageBaseBadgeProps } from './ImageBaseBadge/ImageBaseBadge'; import { type FloatElementIndentation, type FloatElementPlacement, ImageBaseFloatElement, type ImageBaseFloatElementProps, } from './ImageBaseFloatElement/ImageBaseFloatElement'; import { ImageBaseOverlay, type ImageBaseOverlayProps } from './ImageBaseOverlay/ImageBaseOverlay'; import { ImageBaseContext } from './context'; import type { ImageBaseContextProps, ImageBaseExpectedIconProps, ImageBaseSize } from './types'; import { validateFallbackIcon, validateSize } from './validators'; import styles from './ImageBase.module.css'; export type { ImageBaseSize, ImageBaseExpectedIconProps, ImageBaseBadgeProps, ImageBaseOverlayProps, ImageBaseContextProps, ImageBaseFloatElementProps, FloatElementPlacement, FloatElementIndentation, }; export { getBadgeIconSizeByImageBaseSize, getFallbackIconSizeByImageBaseSize, getOverlayIconSizeByImageBaseSize, } from './helpers'; export { ImageBaseContext }; const warn = warnOnce('ImageBase'); /** * Размер по умолчанию. */ const defaultSize = 24; export interface ImageBaseProps extends React.ImgHTMLAttributes, AnchorHTMLAttributesOnly, HasRootRef { /** * Задаёт размер картинки. * * Используйте размеры заданные дизайн-системой `16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 56 | 64 | 72 | 80 | 88 | 96`. * * > ⚠️ Использование кастомного размера – это пограничный кейс. */ size?: LiteralUnion; /** * Ширина изображения. */ widthSize?: number | string; /** * Высота изображения. */ heightSize?: number | string; /** * Отключает обводку. */ noBorder?: boolean; /** * Фолбек на случай, если картинка не прогрузилась. * * > 📝 Нужный для `` размер можно узнать из функции `getFallbackIconSizeByImageBaseSize()`. * * > Предпочтительней использовать иконки из `@vkontakte/icons`. * * > 📊️ Если вы хотите передать кастомную иконку, то следует именовать её по шаблону `Icon`. Или же * > чтобы в неё был передан параметр `width`. Тогда мы сможем выводить в консоль подсказку правильного ли размера вы * > использовали иконку. * * > ⚠️ Может перекрывать `children`. */ fallbackIcon?: React.ReactElement; /** * Отключает фон, заданный по умолчанию. Полезен для отображения картинок с прозрачностью. * @since 5.10.0 */ withTransparentBackground?: boolean; /** * Пользовательское значения стиля object-fit * Подробнее можно почитать в [документации](https://developer.mozilla.org/ru/docs/Web/CSS/object-fit). */ objectFit?: React.CSSProperties['objectFit']; /** * Пользовательское значения стиля object-position * Подробнее можно почитать в [документации](https://developer.mozilla.org/ru/docs/Web/CSS/object-position). */ objectPosition?: React.CSSProperties['objectPosition']; /** * Флаг для сохранения пропорций картинки. * Для корректной работы необходимо задать размеры хотя бы одной стороны картинки. */ keepAspectRatio?: boolean; /** * Смотри https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/elementtiming. */ elementTiming?: string; /** * Пользовательское значения стиля filter * Подробнее можно почитать в [документации](https://developer.mozilla.org/ru/docs/Web/CSS/filter). */ filter?: React.CSSProperties['filter']; /** * Свойства, которые можно прокинуть внутрь компонента: * - `img`: свойства для прокидывания в тег ``;. */ slotProps?: { img?: React.ComponentProps<'img'> & HasRootRef & HasDataAttribute; }; /** * @deprecated Since 7.9.0. Будет удалено в v9. Используйте `slotProps={ img: { getRootRef: ... } }`. */ getRef?: React.Ref; // TODO [>=9] Удалить свойство } const getObjectFitClassName = (objectFit: React.CSSProperties['objectFit']) => { switch (objectFit) { case 'contain': return styles.imgObjectFitContain; case 'cover': return styles.imgObjectFitCover; case 'none': return styles.imgObjectFitNone; case 'scale-down': return styles.imgObjectFitScaleDown; } return undefined; }; const parsePx = (value: string): number | undefined => { if (value.endsWith('px')) { return parseInt(value); } return undefined; }; const sizeToNumber = (size: number | string | undefined): number | undefined => { if (typeof size === 'string') { return parsePx(size); } return size; }; /** * @see https://vkui.io/components/image-base */ export const ImageBase: React.FC & { Badge: typeof ImageBaseBadge; Overlay: typeof ImageBaseOverlay; FloatElement: typeof ImageBaseFloatElement; } = ({ alt, crossOrigin, decoding, loading, referrerPolicy, sizes, src, srcSet, useMap, fetchPriority: fetchPriorityProp, getRef, size: sizeProp, width: widthImg, height: heightImg, widthSize, heightSize, noBorder = false, fallbackIcon: fallbackIconProp, children, onLoad, onError, withTransparentBackground, objectFit = 'cover', objectPosition, filter, keepAspectRatio = false, getRootRef, elementTiming, slotProps, ...restProps }: ImageBaseProps) => { if (process.env.NODE_ENV === 'development') { /* istanbul ignore if: не проверяем в тестах */ if (getRef) { warn( 'Свойство `getRef` устаревшее и будет удалено в VKUI v9. Используйте `slotProps={ img: { getRootRef: ... } }`', ); } } const size = sizeProp ?? minOr([sizeToNumber(widthSize), sizeToNumber(heightSize)], defaultSize); const wrapperRef = useExternRef(getRootRef); const width = widthSize ?? (keepAspectRatio ? undefined : size); const height = heightSize ?? (keepAspectRatio ? undefined : size); const [loaded, setLoaded] = React.useState(false); const [failed, setFailed] = React.useState(false); const mouseOverHandlersRef = useRef([]); const mouseOutHandlersRef = useRef([]); const hasSrc = src || srcSet; const fallbackIcon = failed || !hasSrc ? fallbackIconProp : null; if (process.env.NODE_ENV === 'development') { validateSize(size); if (React.isValidElement(fallbackIcon)) { validateFallbackIcon(size, { name: 'fallbackIcon', value: fallbackIcon }); } } const handleImageLoad = (event: React.SyntheticEvent) => { if (loaded) { return; } setLoaded(true); setFailed(false); onLoad?.(event); }; const handleImageError = (event: React.SyntheticEvent) => { setLoaded(false); setFailed(true); onError?.(event); }; const { getRootRef: getImgRef, fetchPriority, ...imgRest } = useMergeProps & HasRootRef & HasDataAttribute>( hasSrc ? { // safari и firefox нужно чтобы атрибут `loading` был до `src` // // https://mihaly4.ru/image-loading-lazy-bug loading, getRootRef: getRef, alt, className: classNames( styles.img, getObjectFitClassName(objectFit), objectPosition && styles.withObjectPosition, filter && styles.withFilter, keepAspectRatio && styles.imgKeepRatio, failed && styles.imgHiddenAlt, ), crossOrigin, decoding, referrerPolicy, style: mergeStyle( keepAspectRatio ? { width: widthImg || width, height: heightImg || height, } : undefined, objectPosition || filter ? { '--vkui_internal--ImageBase_object_position': objectPosition, '--vkui_internal--ImageBase_object_filter': filter, } : undefined, ), sizes, src, srcSet, useMap, width, height, onLoad: handleImageLoad, onError: handleImageError, fetchPriority: fetchPriorityProp, // @ts-expect-error: TS2322 отсутствует в @types/react elementtiming: elementTiming, // eslint-disable-line react/no-unknown-property } : {}, hasSrc ? slotProps?.img : undefined, ); const imgRef = useExternRef(getImgRef); const isOnLoadStatusCheckedRef = React.useRef(false); React.useEffect( function dispatchLoadEventForAlreadyLoadedResourceIfReactInitializedLater() { if (isOnLoadStatusCheckedRef.current) { return; } isOnLoadStatusCheckedRef.current = true; if (imgRef.current && imgRef.current.complete && !loaded) { const event = new Event('load'); imgRef.current.dispatchEvent(event); } }, [imgRef, loaded], ); const onMouseOver = () => { mouseOverHandlersRef.current.forEach((fn) => fn()); }; const onMouseOut = () => { mouseOutHandlersRef.current.forEach((fn) => fn()); }; const contextValue = React.useMemo( () => ({ size, onMouseOverHandlers: mouseOverHandlersRef.current, onMouseOutHandlers: mouseOutHandlersRef.current, }), [size], ); return ( {hasSrc && } {fallbackIcon &&
{fallbackIcon}
} {children &&
{children}
} {!noBorder &&
} ); }; ImageBase.Badge = ImageBaseBadge; ImageBase.Overlay = ImageBaseOverlay; ImageBase.FloatElement = ImageBaseFloatElement; if (process.env.NODE_ENV !== 'production') { defineComponentDisplayNames(ImageBase.Badge, 'ImageBase.Badge'); defineComponentDisplayNames(ImageBase.Overlay, 'ImageBase.Overlay'); defineComponentDisplayNames(ImageBase.FloatElement, 'ImageBase.FloatElement'); }