diff --git a/.changeset/four-geckos-teach.md b/.changeset/four-geckos-teach.md new file mode 100644 index 0000000000..fcfdff807d --- /dev/null +++ b/.changeset/four-geckos-teach.md @@ -0,0 +1,7 @@ +--- +'@alfalab/core-components-icon-view': minor +'@alfalab/core-components-file-upload-item': patch +--- + +- В `icon-view` добавлен callback `onImageBrokenChange` и улучшена обработка битых изображений в `BaseShape`. +- В `file-upload-item` для битого `imageUrl` добавлено отображение fallback-иконки `DocumentImageOff`. diff --git a/packages/file-upload-item/src/Component.tsx b/packages/file-upload-item/src/Component.tsx index f04ed5d9a9..0ece569624 100644 --- a/packages/file-upload-item/src/Component.tsx +++ b/packages/file-upload-item/src/Component.tsx @@ -40,6 +40,7 @@ export const FileUploadItemComponent: React.FC = ({ backgroundColor, }) => { const [actionsPresent, setActionsPresent] = useState(false); + const [isBrokenImage, setIsBrokenImage] = useState(false); return (
= ({ customContent, truncate, imageUrl, + isBrokenImage, + setIsBrokenImage, backgroundColor, actionsPresent, setActionsPresent, diff --git a/packages/file-upload-item/src/components/status-control/extension-icon/index.test.tsx b/packages/file-upload-item/src/components/status-control/extension-icon/index.test.tsx new file mode 100644 index 0000000000..53d9d5f069 --- /dev/null +++ b/packages/file-upload-item/src/components/status-control/extension-icon/index.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { FileUploadItemContext } from '../../../context/file-upload-item-context'; + +import { ExtensionIcon } from '.'; + +jest.mock('@alfalab/icons-glyph/DocumentImageOffMIcon', () => ({ + DocumentImageOffMIcon: () =>
, +})); + +describe('ExtensionIcon', () => { + it('should hide icon when image is valid', () => { + const CustomIcon = () =>
; + + render( + + + , + ); + + expect(screen.queryByTestId('custom-icon')).not.toBeInTheDocument(); + }); + + it('should show fallback icon when image is broken', () => { + render( + + + , + ); + + expect(screen.getByTestId('broken-image-icon')).toBeInTheDocument(); + }); + + it('should keep custom icon priority for broken image', () => { + const CustomIcon = () =>
; + + render( + + + , + ); + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); +}); diff --git a/packages/file-upload-item/src/components/status-control/extension-icon/index.tsx b/packages/file-upload-item/src/components/status-control/extension-icon/index.tsx index df681b92b7..eeb70eaad1 100644 --- a/packages/file-upload-item/src/components/status-control/extension-icon/index.tsx +++ b/packages/file-upload-item/src/components/status-control/extension-icon/index.tsx @@ -5,6 +5,7 @@ import { Document1CMIcon } from '@alfalab/icons-glyph/Document1CMIcon'; import { DocumentDocMIcon } from '@alfalab/icons-glyph/DocumentDocMIcon'; import { DocumentExcelMIcon } from '@alfalab/icons-glyph/DocumentExcelMIcon'; import { DocumentImageMIcon } from '@alfalab/icons-glyph/DocumentImageMIcon'; +import { DocumentImageOffMIcon } from '@alfalab/icons-glyph/DocumentImageOffMIcon'; import { DocumentMIcon } from '@alfalab/icons-glyph/DocumentMIcon'; import { DocumentOffMIcon } from '@alfalab/icons-glyph/DocumentOffMIcon'; import { DocumentPdfMIcon } from '@alfalab/icons-glyph/DocumentPdfMIcon'; @@ -15,33 +16,7 @@ import { getExtension, isInitialStatus } from '../../../utils'; import styles from './index.module.css'; -export const ExtensionIcon = () => { - const { - title = '', - uploadStatus, - iconStyle, - customIcon: CustomIcon, - imageUrl, - showRestore, - } = useContext(FileUploadItemContext); - - if (imageUrl) { - return null; - } - - if (CustomIcon) { - return ; - } - - if (isInitialStatus(uploadStatus)) { - return ; - } - - if (showRestore) { - return ; - } - - const isColoredIcon = iconStyle === 'colored'; +const getDefaultFileIcon = (title: string, isColoredIcon: boolean) => { const fileType = getExtension(title); switch (fileType) { @@ -72,3 +47,39 @@ export const ExtensionIcon = () => { ); } }; + +export const ExtensionIcon = () => { + const { + title = '', + uploadStatus, + iconStyle, + customIcon: CustomIcon, + imageUrl, + isBrokenImage, + showRestore, + } = useContext(FileUploadItemContext); + + if (imageUrl && !isBrokenImage) { + return null; + } + + if (imageUrl && isBrokenImage && !CustomIcon) { + return ; + } + + if (CustomIcon) { + return ; + } + + if (isInitialStatus(uploadStatus)) { + return ; + } + + if (showRestore) { + return ; + } + + const isColoredIcon = iconStyle === 'colored'; + + return getDefaultFileIcon(title, isColoredIcon); +}; diff --git a/packages/file-upload-item/src/components/status-control/index.tsx b/packages/file-upload-item/src/components/status-control/index.tsx index b275d042aa..86afadb713 100644 --- a/packages/file-upload-item/src/components/status-control/index.tsx +++ b/packages/file-upload-item/src/components/status-control/index.tsx @@ -17,6 +17,7 @@ export const StatusControl = () => { progressBar = 0, progressBarAvailable = true, imageUrl, + setIsBrokenImage, backgroundColor, actionsPresent, isClickable, @@ -37,6 +38,7 @@ export const StatusControl = () => { diff --git a/packages/file-upload-item/src/context/file-upload-item-context.ts b/packages/file-upload-item/src/context/file-upload-item-context.ts index 5147b0ac16..3f74192e0e 100644 --- a/packages/file-upload-item/src/context/file-upload-item-context.ts +++ b/packages/file-upload-item/src/context/file-upload-item-context.ts @@ -34,6 +34,8 @@ type TFileUploadItemContext = { customContent?: ElementType; truncate?: boolean; imageUrl?: string; + isBrokenImage?: boolean; + setIsBrokenImage?: (isBroken: boolean) => void; backgroundColor?: SuperEllipseProps['backgroundColor']; actionsPresent?: boolean; setActionsPresent?: (present: boolean) => void; @@ -64,6 +66,8 @@ export const FileUploadItemContext = createContext({ customContent: undefined, truncate: false, imageUrl: undefined, + isBrokenImage: false, + setIsBrokenImage: undefined, backgroundColor: undefined, actionsPresent: false, setActionsPresent: undefined, diff --git a/packages/icon-view/src/components/base-shape/check-image-is-broken.test.ts b/packages/icon-view/src/components/base-shape/check-image-is-broken.test.ts new file mode 100644 index 0000000000..4b716c687b --- /dev/null +++ b/packages/icon-view/src/components/base-shape/check-image-is-broken.test.ts @@ -0,0 +1,119 @@ +import { checkImageIsBroken } from './check-image-is-broken'; + +type EventType = 'load' | 'error'; + +type MockImageConstructorParams = { + eventType: EventType; + decode: () => Promise; +}; + +const getMockImageConstructor = ({ eventType, decode }: MockImageConstructorParams) => { + function MockImage(this: { + onload: null | (() => void); + onerror: null | (() => void); + decode: () => Promise; + }) { + this.onload = null; + this.onerror = null; + this.decode = decode; + } + + Object.defineProperty(MockImage.prototype, 'src', { + set(this: { onload: null | (() => void); onerror: null | (() => void) }) { + if (eventType === 'load') { + this.onload?.(); + } else { + this.onerror?.(); + } + }, + }); + + return MockImage as unknown as typeof Image; +}; + +describe('checkImageIsBroken', () => { + const originalCreateImageBitmap = global.createImageBitmap; + const originalImage = global.Image; + + afterEach(() => { + global.createImageBitmap = originalCreateImageBitmap; + global.Image = originalImage; + }); + + it('should return false for valid image via createImageBitmap', async () => { + const close = jest.fn(); + + global.createImageBitmap = jest.fn().mockResolvedValue({ + close, + } as unknown as ImageBitmap); + global.Image = getMockImageConstructor({ + eventType: 'load', + decode: jest.fn().mockResolvedValue(undefined), + }); + + await new Promise((resolve) => { + checkImageIsBroken({ + imageUrl: 'https://test-url', + onResolve: (isBroken) => { + expect(isBroken).toBe(false); + expect(close).toHaveBeenCalled(); + resolve(); + }, + }); + }); + }); + + it('should use decode fallback when createImageBitmap fails', async () => { + global.createImageBitmap = jest.fn().mockRejectedValue(new Error('bitmap failed')); + global.Image = getMockImageConstructor({ + eventType: 'load', + decode: jest.fn().mockResolvedValue(undefined), + }); + + await new Promise((resolve) => { + checkImageIsBroken({ + imageUrl: 'https://test-url', + onResolve: (isBroken) => { + expect(isBroken).toBe(false); + resolve(); + }, + }); + }); + }); + + it('should return true when decode fallback also fails', async () => { + global.createImageBitmap = jest.fn().mockRejectedValue(new Error('bitmap failed')); + global.Image = getMockImageConstructor({ + eventType: 'load', + decode: jest.fn().mockRejectedValue(new Error('decode failed')), + }); + + await new Promise((resolve) => { + checkImageIsBroken({ + imageUrl: 'https://test-url', + onResolve: (isBroken) => { + expect(isBroken).toBe(true); + resolve(); + }, + }); + }); + }); + + it('should return true on image onerror', async () => { + global.createImageBitmap = jest.fn(); + global.Image = getMockImageConstructor({ + eventType: 'error', + decode: jest.fn().mockResolvedValue(undefined), + }); + + await new Promise((resolve) => { + checkImageIsBroken({ + imageUrl: 'https://test-url', + onResolve: (isBroken) => { + expect(isBroken).toBe(true); + resolve(); + }, + }); + }); + }); +}); diff --git a/packages/icon-view/src/components/base-shape/check-image-is-broken.ts b/packages/icon-view/src/components/base-shape/check-image-is-broken.ts new file mode 100644 index 0000000000..10f251f28c --- /dev/null +++ b/packages/icon-view/src/components/base-shape/check-image-is-broken.ts @@ -0,0 +1,37 @@ +type CheckImageIsBrokenParams = { + imageUrl: string; + onResolve: (isBroken: boolean) => void; +}; + +export const checkImageIsBroken = ({ imageUrl, onResolve }: CheckImageIsBrokenParams) => { + const image = new Image(); + + const resolveWithDecode = () => { + Promise.resolve() + .then(() => image.decode()) + .then(() => { + onResolve(false); + }) + .catch(() => { + onResolve(true); + }); + }; + + image.onload = () => { + Promise.resolve() + .then(() => createImageBitmap(image)) + .then((imageBitmap) => { + imageBitmap.close(); + onResolve(false); + }) + .catch(() => { + resolveWithDecode(); + }); + }; + + image.onerror = () => { + onResolve(true); + }; + + image.src = imageUrl; +}; diff --git a/packages/icon-view/src/components/base-shape/component.tsx b/packages/icon-view/src/components/base-shape/component.tsx index a40ca36492..fb942ffdde 100644 --- a/packages/icon-view/src/components/base-shape/component.tsx +++ b/packages/icon-view/src/components/base-shape/component.tsx @@ -3,6 +3,7 @@ import cn from 'classnames'; import { useId, useImageLoadingState } from '@alfalab/hooks'; +import { useCheckImageIsBroken } from './use-check-image-is-broken'; import { getPath, getPathName, type PathsMap } from './utils'; import styles from './index.module.css'; @@ -47,6 +48,12 @@ export type BaseShapeProps = { */ imageUrl?: string; + /** + * Колбек изменения статуса изображения. + * Возвращает true, если изображение не удалось загрузить/декодировать. + */ + onImageBrokenChange?: (isBrokenImage: boolean) => void; + /** * Режим масштабирования изображения * 'fill' - изображение заполняет всё доступное пространство и может быть обрезано @@ -118,6 +125,7 @@ export const BaseShape = forwardRef( border = false, backgroundColor, imageUrl, + onImageBrokenChange, scale = 'fill', backgroundIcon: Icon, className, @@ -136,6 +144,7 @@ export const BaseShape = forwardRef( ) => { const [width, height] = typeof size === 'object' ? [size.width, size.height] : [size, size]; const imageLoadingState = useImageLoadingState({ src: imageUrl || '' }); + const isBrokenImage = useCheckImageIsBroken({ imageUrl, onImageBrokenChange }); const clipPathId = useId(); @@ -188,7 +197,7 @@ export const BaseShape = forwardRef( d={shapeDPath} /> - {imageUrl && imageLoadingState !== 'error' && ( + {imageUrl && imageLoadingState !== 'error' && !isBrokenImage && ( diff --git a/packages/icon-view/src/components/base-shape/use-check-image-is-broken.test.ts b/packages/icon-view/src/components/base-shape/use-check-image-is-broken.test.ts new file mode 100644 index 0000000000..d99632a874 --- /dev/null +++ b/packages/icon-view/src/components/base-shape/use-check-image-is-broken.test.ts @@ -0,0 +1,87 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import { checkImageIsBroken } from './check-image-is-broken'; +import { useCheckImageIsBroken } from './use-check-image-is-broken'; + +jest.mock('./check-image-is-broken', () => ({ + checkImageIsBroken: jest.fn(), +})); + +const mockedCheckImageIsBroken = jest.mocked(checkImageIsBroken); + +describe('useCheckImageIsBroken', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should keep default state for empty imageUrl', () => { + const onImageBrokenChange = jest.fn(); + const { result } = renderHook(() => + useCheckImageIsBroken({ imageUrl: undefined, onImageBrokenChange }), + ); + + expect(result.current).toBe(false); + expect(onImageBrokenChange).toHaveBeenCalledWith(false); + expect(mockedCheckImageIsBroken).not.toHaveBeenCalled(); + }); + + it('should set false for valid image', () => { + mockedCheckImageIsBroken.mockImplementation(({ onResolve }) => { + onResolve(false); + }); + + const onImageBrokenChange = jest.fn(); + const { result } = renderHook(() => + useCheckImageIsBroken({ imageUrl: 'https://valid-image', onImageBrokenChange }), + ); + + expect(result.current).toBe(false); + expect(onImageBrokenChange).toHaveBeenNthCalledWith(1, false); + expect(onImageBrokenChange).toHaveBeenNthCalledWith(2, false); + }); + + it('should set true for broken image', () => { + mockedCheckImageIsBroken.mockImplementation(({ onResolve }) => { + onResolve(true); + }); + + const onImageBrokenChange = jest.fn(); + const { result } = renderHook(() => + useCheckImageIsBroken({ imageUrl: 'https://broken-image', onImageBrokenChange }), + ); + + expect(result.current).toBe(true); + expect(onImageBrokenChange).toHaveBeenNthCalledWith(1, false); + expect(onImageBrokenChange).toHaveBeenNthCalledWith(2, true); + }); + + it('should ignore stale check result after image change', () => { + const resolvers: Record void> = {}; + + mockedCheckImageIsBroken.mockImplementation(({ imageUrl, onResolve }) => { + resolvers[imageUrl] = onResolve; + }); + + const onImageBrokenChange = jest.fn(); + const { result, rerender } = renderHook( + ({ imageUrl }: { imageUrl?: string }) => + useCheckImageIsBroken({ imageUrl, onImageBrokenChange }), + { initialProps: { imageUrl: 'https://first-image' } }, + ); + + rerender({ imageUrl: 'https://second-image' }); + + act(() => { + resolvers['https://first-image'](true); + }); + + expect(result.current).toBe(false); + + act(() => { + resolvers['https://second-image'](true); + }); + + expect(result.current).toBe(true); + expect(onImageBrokenChange).toHaveBeenLastCalledWith(true); + }); +}); diff --git a/packages/icon-view/src/components/base-shape/use-check-image-is-broken.ts b/packages/icon-view/src/components/base-shape/use-check-image-is-broken.ts new file mode 100644 index 0000000000..37798d2cad --- /dev/null +++ b/packages/icon-view/src/components/base-shape/use-check-image-is-broken.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; + +import { checkImageIsBroken } from './check-image-is-broken'; + +type Params = { + imageUrl?: string; + onImageBrokenChange?: (isBrokenImage: boolean) => void; +}; + +export const useCheckImageIsBroken = ({ imageUrl, onImageBrokenChange }: Params) => { + const [isBrokenImage, setIsBrokenImage] = useState(false); + + useEffect(() => { + let isActive = true; + + setIsBrokenImage(false); + onImageBrokenChange?.(false); + + if (!imageUrl) { + return () => { + isActive = false; + }; + } + + checkImageIsBroken({ + imageUrl, + onResolve: (isBroken) => { + if (!isActive) { + return; + } + + setIsBrokenImage(isBroken); + onImageBrokenChange?.(isBroken); + }, + }); + + return () => { + isActive = false; + }; + }, [imageUrl, onImageBrokenChange]); + + return isBrokenImage; +};