@shopify/shop-minis-react 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/atoms/list.js +57 -65
- package/dist/components/atoms/list.js.map +1 -1
- package/dist/hooks/util/useImagePicker.js +13 -6
- package/dist/hooks/util/useImagePicker.js.map +1 -1
- package/dist/internal/utils/resizeImage.js +61 -0
- package/dist/internal/utils/resizeImage.js.map +1 -0
- package/dist/providers/ImagePickerProvider.js +123 -102
- package/dist/providers/ImagePickerProvider.js.map +1 -1
- package/package.json +1 -1
- package/src/components/atoms/list.tsx +3 -12
- package/src/hooks/util/useImagePicker.test.tsx +193 -0
- package/src/hooks/util/useImagePicker.ts +24 -5
- package/src/internal/utils/resizeImage.test.ts +314 -0
- package/src/internal/utils/resizeImage.ts +108 -0
- package/src/providers/ImagePickerProvider.test.tsx +32 -1
- package/src/providers/ImagePickerProvider.tsx +108 -65
- package/dist/components/atoms/pagination.js +0 -10
- package/dist/components/atoms/pagination.js.map +0 -1
- package/dist/components/atoms/tracking-pixel.js +0 -32
- package/dist/components/atoms/tracking-pixel.js.map +0 -1
- package/dist/shop-minis-react/node_modules/.pnpm/react-intersection-observer@9.13.1_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/react-intersection-observer/dist/index.js +0 -135
- package/dist/shop-minis-react/node_modules/.pnpm/react-intersection-observer@9.13.1_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/react-intersection-observer/dist/index.js.map +0 -1
- package/src/components/atoms/pagination.tsx +0 -19
- package/src/components/atoms/tracking-pixel.tsx +0 -40
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ImagePickerProvider.js","sources":["../../src/providers/ImagePickerProvider.tsx"],"sourcesContent":["import React, {\n createContext,\n useContext,\n useRef,\n useCallback,\n useEffect,\n useMemo,\n} from 'react'\n\nimport {useRequestPermissions} from '../hooks/util/useRequestPermissions'\nimport {useReportInteraction} from '../internal/useReportInteraction'\n\nexport type CameraFacing = 'front' | 'back'\n\ninterface ImagePickerContextValue {\n openCamera: (cameraFacing?: CameraFacing) => Promise<File>\n openGallery: () => Promise<File>\n}\n\nconst ImagePickerContext = createContext<ImagePickerContextValue | null>(null)\n\nexport function useImagePickerContext() {\n const context = useContext(ImagePickerContext)\n if (!context) {\n throw new Error(\n 'useImagePickerContext must be used within an ImagePickerProvider'\n )\n }\n return context\n}\n\ninterface ImagePickerProviderProps {\n children: React.ReactNode\n}\n\nexport function ImagePickerProvider({children}: ImagePickerProviderProps) {\n const galleryInputRef = useRef<HTMLInputElement>(null)\n const frontCameraInputRef = useRef<HTMLInputElement>(null)\n const backCameraInputRef = useRef<HTMLInputElement>(null)\n const resolveRef = useRef<((file: File) => void) | null>(null)\n const rejectRef = useRef<((reason: Error) => void) | null>(null)\n const activeCancelHandlerRef = useRef<{\n input: HTMLInputElement\n handler: () => void\n } | null>(null)\n const activeOperationRef = useRef<'gallery' | 'camera' | null>(null)\n\n const {requestPermission} = useRequestPermissions()\n const {reportInteraction} = useReportInteraction()\n\n const cleanupCancelHandler = useCallback(() => {\n if (activeCancelHandlerRef.current) {\n const {input, handler} = activeCancelHandlerRef.current\n input.removeEventListener('cancel', handler)\n activeCancelHandlerRef.current = null\n }\n }, [])\n\n const rejectPendingPromise = useCallback(() => {\n if (rejectRef.current) {\n const error = new Error(\n 'New file picker opened before previous completed'\n )\n if (activeOperationRef.current === 'gallery') {\n reportInteraction({\n interactionType: 'image_picker_error',\n interactionValue: error.message,\n })\n } else if (activeOperationRef.current === 'camera') {\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n }\n\n rejectRef.current(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n }\n }, [reportInteraction])\n\n const handleFileChange = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>) => {\n const file = event.target.files?.[0]\n\n if (file && resolveRef.current) {\n // Report success based on the active operation\n if (activeOperationRef.current === 'gallery') {\n reportInteraction({\n interactionType: 'image_picker_success',\n })\n } else if (activeOperationRef.current === 'camera') {\n reportInteraction({\n interactionType: 'camera_success',\n })\n }\n\n resolveRef.current(file)\n\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n\n cleanupCancelHandler()\n }\n\n event.target.value = ''\n },\n [cleanupCancelHandler, reportInteraction]\n )\n\n const openGallery = useCallback(() => {\n return new Promise<File>((resolve, reject) => {\n rejectPendingPromise()\n cleanupCancelHandler()\n\n resolveRef.current = resolve\n rejectRef.current = reject\n activeOperationRef.current = 'gallery'\n\n const input = galleryInputRef.current\n\n if (!input) {\n const error = new Error('Gallery input not found')\n reportInteraction({\n interactionType: 'image_picker_error',\n interactionValue: error.message,\n })\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n return\n }\n\n const handleCancel = () => {\n if (rejectRef.current) {\n const error = new Error('User cancelled file selection')\n reportInteraction({\n interactionType: 'image_picker_error',\n interactionValue: error.message,\n })\n rejectRef.current(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n }\n cleanupCancelHandler()\n }\n\n input.addEventListener('cancel', handleCancel)\n activeCancelHandlerRef.current = {input, handler: handleCancel}\n\n reportInteraction({\n interactionType: 'image_picker_open',\n })\n\n requestPermission({permission: 'CAMERA'})\n .then(() => {\n // This will show both Camera and Gallery\n input.click()\n })\n .catch(() => {\n // Show only Gallery\n input.click()\n })\n })\n }, [\n rejectPendingPromise,\n cleanupCancelHandler,\n requestPermission,\n reportInteraction,\n ])\n\n const openCamera = useCallback(\n (cameraFacing: CameraFacing = 'back') => {\n return new Promise<File>((resolve, reject) => {\n rejectPendingPromise()\n cleanupCancelHandler()\n\n resolveRef.current = resolve\n rejectRef.current = reject\n activeOperationRef.current = 'camera'\n\n const input =\n cameraFacing === 'front'\n ? frontCameraInputRef.current\n : backCameraInputRef.current\n\n if (!input) {\n const error = new Error('Camera input not found')\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n return\n }\n\n const handleCancel = () => {\n if (rejectRef.current) {\n const error = new Error('User cancelled camera')\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n rejectRef.current(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n }\n cleanupCancelHandler()\n }\n\n input.addEventListener('cancel', handleCancel)\n activeCancelHandlerRef.current = {input, handler: handleCancel}\n\n reportInteraction({\n interactionType: 'camera_open',\n })\n\n requestPermission({permission: 'CAMERA'})\n .then(({granted}) => {\n if (granted) {\n input.click()\n } else {\n const error = new Error('Camera permission not granted')\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n }\n })\n .catch(() => {\n const error = new Error('Camera permission not granted')\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n })\n })\n },\n [\n rejectPendingPromise,\n cleanupCancelHandler,\n requestPermission,\n reportInteraction,\n ]\n )\n\n useEffect(() => {\n return () => {\n rejectPendingPromise()\n cleanupCancelHandler()\n }\n }, [rejectPendingPromise, cleanupCancelHandler])\n\n const contextValue: ImagePickerContextValue = useMemo(\n () => ({\n openCamera,\n openGallery,\n }),\n [openCamera, openGallery]\n )\n\n return (\n <ImagePickerContext.Provider value={contextValue}>\n {children}\n <input\n ref={galleryInputRef}\n type=\"file\"\n accept=\"image/*\"\n onChange={handleFileChange}\n style={{display: 'none'}}\n aria-hidden=\"true\"\n />\n <input\n ref={frontCameraInputRef}\n type=\"file\"\n accept=\"image/*\"\n capture=\"user\"\n onChange={handleFileChange}\n style={{display: 'none'}}\n aria-hidden=\"true\"\n />\n <input\n ref={backCameraInputRef}\n type=\"file\"\n accept=\"image/*\"\n capture=\"environment\"\n onChange={handleFileChange}\n style={{display: 'none'}}\n aria-hidden=\"true\"\n />\n </ImagePickerContext.Provider>\n )\n}\n"],"names":["ImagePickerContext","createContext","useImagePickerContext","context","useContext","ImagePickerProvider","children","galleryInputRef","useRef","frontCameraInputRef","backCameraInputRef","resolveRef","rejectRef","activeCancelHandlerRef","activeOperationRef","requestPermission","useRequestPermissions","reportInteraction","useReportInteraction","cleanupCancelHandler","useCallback","input","handler","rejectPendingPromise","error","handleFileChange","event","file","openGallery","resolve","reject","handleCancel","openCamera","cameraFacing","granted","useEffect","contextValue","useMemo","jsxs","jsx"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ImagePickerProvider.js","sources":["../../src/providers/ImagePickerProvider.tsx"],"sourcesContent":["import React, {\n createContext,\n useContext,\n useRef,\n useCallback,\n useEffect,\n useMemo,\n} from 'react'\n\nimport {useRequestPermissions} from '../hooks/util/useRequestPermissions'\nimport {useReportInteraction} from '../internal/useReportInteraction'\nimport {resizeImage} from '../internal/utils/resizeImage'\n\nexport type ImageQuality = 'low' | 'medium' | 'high' | 'original'\nexport type CameraFacing = 'front' | 'back'\nexport interface CustomImageQuality {\n size?: number\n compression?: number\n}\n\nexport interface OpenCameraParams {\n cameraFacing?: CameraFacing\n quality?: ImageQuality\n customQuality?: CustomImageQuality\n}\n\nexport interface OpenGalleryParams {\n quality?: ImageQuality\n customQuality?: CustomImageQuality\n}\n\ninterface ImagePickerContextValue {\n openCamera: (params?: OpenCameraParams) => Promise<File>\n openGallery: (params?: OpenGalleryParams) => Promise<File>\n}\n\nconst ImagePickerContext = createContext<ImagePickerContextValue | null>(null)\n\nexport function useImagePickerContext() {\n const context = useContext(ImagePickerContext)\n if (!context) {\n throw new Error(\n 'useImagePickerContext must be used within an ImagePickerProvider'\n )\n }\n return context\n}\n\ninterface ImagePickerProviderProps {\n children: React.ReactNode\n}\n\nexport function ImagePickerProvider({children}: ImagePickerProviderProps) {\n const galleryInputRef = useRef<HTMLInputElement>(null)\n const frontCameraInputRef = useRef<HTMLInputElement>(null)\n const backCameraInputRef = useRef<HTMLInputElement>(null)\n const resolveRef = useRef<((file: File) => void) | null>(null)\n const rejectRef = useRef<((reason: Error) => void) | null>(null)\n const activeCancelHandlerRef = useRef<{\n input: HTMLInputElement\n handler: () => void\n } | null>(null)\n const activeOperationRef = useRef<'gallery' | 'camera' | null>(null)\n const qualityRef = useRef<ImageQuality>('medium')\n const customQualityRef = useRef<CustomImageQuality>(undefined)\n const {requestPermission} = useRequestPermissions()\n const {reportInteraction} = useReportInteraction()\n\n const cleanupCancelHandler = useCallback(() => {\n if (activeCancelHandlerRef.current) {\n const {input, handler} = activeCancelHandlerRef.current\n input.removeEventListener('cancel', handler)\n activeCancelHandlerRef.current = null\n }\n }, [])\n\n const rejectPendingPromise = useCallback(() => {\n if (rejectRef.current) {\n const error = new Error(\n 'New file picker opened before previous completed'\n )\n if (activeOperationRef.current === 'gallery') {\n reportInteraction({\n interactionType: 'image_picker_error',\n interactionValue: error.message,\n })\n } else if (activeOperationRef.current === 'camera') {\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n }\n\n rejectRef.current(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n qualityRef.current = 'medium'\n customQualityRef.current = undefined\n }\n }, [reportInteraction])\n\n const handleFileChange = useCallback(\n async (event: React.ChangeEvent<HTMLInputElement>) => {\n const {target} = event\n const file = target.files?.[0]\n\n if (file && resolveRef.current) {\n try {\n const resizedFile = await resizeImage({\n file,\n quality: qualityRef.current,\n customQuality: customQualityRef.current,\n })\n\n if (activeOperationRef.current === 'gallery') {\n reportInteraction({\n interactionType: 'image_picker_success',\n })\n } else if (activeOperationRef.current === 'camera') {\n reportInteraction({\n interactionType: 'camera_success',\n })\n }\n\n resolveRef.current(resizedFile)\n } catch (error) {\n console.warn('Image resize failed, using original:', error)\n if (resolveRef.current) {\n resolveRef.current(file)\n }\n }\n\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n\n cleanupCancelHandler()\n }\n\n target.value = ''\n },\n [cleanupCancelHandler, reportInteraction]\n )\n\n const openGallery = useCallback(\n ({quality = 'medium', customQuality}: OpenGalleryParams = {}) => {\n return new Promise<File>((resolve, reject) => {\n rejectPendingPromise()\n cleanupCancelHandler()\n\n qualityRef.current = quality\n customQualityRef.current = customQuality\n resolveRef.current = resolve\n rejectRef.current = reject\n activeOperationRef.current = 'gallery'\n const input = galleryInputRef.current\n\n if (!input) {\n const error = new Error('Gallery input not found')\n reportInteraction({\n interactionType: 'image_picker_error',\n interactionValue: error.message,\n })\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n return\n }\n\n const handleCancel = () => {\n if (rejectRef.current) {\n const error = new Error('User cancelled file selection')\n reportInteraction({\n interactionType: 'image_picker_error',\n interactionValue: error.message,\n })\n rejectRef.current(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n }\n cleanupCancelHandler()\n }\n\n input.addEventListener('cancel', handleCancel)\n activeCancelHandlerRef.current = {input, handler: handleCancel}\n\n reportInteraction({\n interactionType: 'image_picker_open',\n })\n\n requestPermission({permission: 'CAMERA'})\n .then(() => {\n // This will show both Camera and Gallery\n input.click()\n })\n .catch(() => {\n // Show only Gallery\n input.click()\n })\n })\n },\n [\n rejectPendingPromise,\n cleanupCancelHandler,\n requestPermission,\n reportInteraction,\n ]\n )\n\n const openCamera = useCallback(\n ({\n cameraFacing = 'back',\n quality = 'medium',\n customQuality,\n }: OpenCameraParams = {}) => {\n return new Promise<File>((resolve, reject) => {\n rejectPendingPromise()\n cleanupCancelHandler()\n\n qualityRef.current = quality\n customQualityRef.current = customQuality\n resolveRef.current = resolve\n rejectRef.current = reject\n activeOperationRef.current = 'camera'\n\n const input =\n cameraFacing === 'front'\n ? frontCameraInputRef.current\n : backCameraInputRef.current\n\n if (!input) {\n const error = new Error('Camera input not found')\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n return\n }\n\n const handleCancel = () => {\n if (rejectRef.current) {\n const error = new Error('User cancelled camera')\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n rejectRef.current(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n }\n cleanupCancelHandler()\n }\n\n input.addEventListener('cancel', handleCancel)\n activeCancelHandlerRef.current = {input, handler: handleCancel}\n\n reportInteraction({\n interactionType: 'camera_open',\n })\n\n requestPermission({permission: 'CAMERA'})\n .then(({granted}) => {\n if (granted) {\n input.click()\n } else {\n const error = new Error('Camera permission not granted')\n reportInteraction({\n interactionType: 'camera_error',\n interactionValue: error.message,\n })\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n }\n })\n .catch(() => {\n const error = new Error('Camera permission not granted')\n reject(error)\n resolveRef.current = null\n rejectRef.current = null\n activeOperationRef.current = null\n })\n })\n },\n [\n rejectPendingPromise,\n cleanupCancelHandler,\n requestPermission,\n reportInteraction,\n ]\n )\n\n useEffect(() => {\n return () => {\n rejectPendingPromise()\n cleanupCancelHandler()\n }\n }, [rejectPendingPromise, cleanupCancelHandler])\n\n const contextValue: ImagePickerContextValue = useMemo(\n () => ({\n openCamera,\n openGallery,\n }),\n [openCamera, openGallery]\n )\n\n return (\n <ImagePickerContext.Provider value={contextValue}>\n {children}\n <input\n ref={galleryInputRef}\n type=\"file\"\n accept=\"image/*\"\n onChange={handleFileChange}\n style={{display: 'none'}}\n aria-hidden=\"true\"\n />\n <input\n ref={frontCameraInputRef}\n type=\"file\"\n accept=\"image/*\"\n capture=\"user\"\n onChange={handleFileChange}\n style={{display: 'none'}}\n aria-hidden=\"true\"\n />\n <input\n ref={backCameraInputRef}\n type=\"file\"\n accept=\"image/*\"\n capture=\"environment\"\n onChange={handleFileChange}\n style={{display: 'none'}}\n aria-hidden=\"true\"\n />\n </ImagePickerContext.Provider>\n )\n}\n"],"names":["ImagePickerContext","createContext","useImagePickerContext","context","useContext","ImagePickerProvider","children","galleryInputRef","useRef","frontCameraInputRef","backCameraInputRef","resolveRef","rejectRef","activeCancelHandlerRef","activeOperationRef","qualityRef","customQualityRef","requestPermission","useRequestPermissions","reportInteraction","useReportInteraction","cleanupCancelHandler","useCallback","input","handler","rejectPendingPromise","error","handleFileChange","event","target","file","resizedFile","resizeImage","openGallery","quality","customQuality","resolve","reject","handleCancel","openCamera","cameraFacing","granted","useEffect","contextValue","useMemo","jsxs","jsx"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -7,8 +7,7 @@ import {usePullToRefresh} from '../../internal/usePullToRefresh'
|
|
|
7
7
|
import {findVirtuosoScrollableElement} from '../../internal/utils/virtuoso-dom'
|
|
8
8
|
import {cn} from '../../lib/utils'
|
|
9
9
|
import '../../styles/utilities.css'
|
|
10
|
-
|
|
11
|
-
import {Pagination} from './pagination'
|
|
10
|
+
import {Skeleton} from '../ui/skeleton'
|
|
12
11
|
|
|
13
12
|
const DEFAULT_REFRESH_PULL_THRESHOLD = 200
|
|
14
13
|
const ELEMENT_BIND_DELAY = 100
|
|
@@ -24,7 +23,6 @@ interface Props<T = any>
|
|
|
24
23
|
header?: React.ReactNode
|
|
25
24
|
fetchMore?: () => Promise<void>
|
|
26
25
|
loadingComponent?: React.ReactNode
|
|
27
|
-
isFetchingMore?: boolean
|
|
28
26
|
onRefresh?: () => Promise<void>
|
|
29
27
|
refreshing?: boolean
|
|
30
28
|
enablePullToRefresh?: boolean
|
|
@@ -39,7 +37,6 @@ export function List<T = any>({
|
|
|
39
37
|
header,
|
|
40
38
|
fetchMore,
|
|
41
39
|
loadingComponent,
|
|
42
|
-
isFetchingMore,
|
|
43
40
|
onRefresh,
|
|
44
41
|
refreshing,
|
|
45
42
|
enablePullToRefresh = true,
|
|
@@ -77,14 +74,8 @@ export function List<T = any>({
|
|
|
77
74
|
const Footer = useCallback(() => {
|
|
78
75
|
if (!fetchMore) return null
|
|
79
76
|
|
|
80
|
-
return
|
|
81
|
-
|
|
82
|
-
fetchMore={_fetchMore}
|
|
83
|
-
loadingComponent={loadingComponent}
|
|
84
|
-
isFetchingMore={isFetchingMore}
|
|
85
|
-
/>
|
|
86
|
-
)
|
|
87
|
-
}, [_fetchMore, fetchMore, loadingComponent, isFetchingMore])
|
|
77
|
+
return loadingComponent ?? <Skeleton className="h-10 w-full p-8" />
|
|
78
|
+
}, [loadingComponent, fetchMore])
|
|
88
79
|
|
|
89
80
|
const classNames = cn(showScrollbar ? undefined : 'no-scrollbars', className)
|
|
90
81
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import {renderHook} from '@testing-library/react'
|
|
4
|
+
import {describe, expect, it, vi, beforeEach} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {useImagePicker} from './useImagePicker'
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
OpenCameraParams,
|
|
10
|
+
OpenGalleryParams,
|
|
11
|
+
} from '../../providers/ImagePickerProvider'
|
|
12
|
+
|
|
13
|
+
// Mock the ImagePickerProvider context
|
|
14
|
+
const mockOpenCamera = vi.fn()
|
|
15
|
+
const mockOpenGallery = vi.fn()
|
|
16
|
+
|
|
17
|
+
vi.mock('../../providers/ImagePickerProvider', () => ({
|
|
18
|
+
useImagePickerContext: () => ({
|
|
19
|
+
openCamera: mockOpenCamera,
|
|
20
|
+
openGallery: mockOpenGallery,
|
|
21
|
+
}),
|
|
22
|
+
ImagePickerProvider: ({children}: {children: React.ReactNode}) => children,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
describe('useImagePicker', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('openCamera', () => {
|
|
31
|
+
it('calls context openCamera with provided params', async () => {
|
|
32
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
33
|
+
mockOpenCamera.mockResolvedValue(mockFile)
|
|
34
|
+
|
|
35
|
+
const {result} = renderHook(() => useImagePicker())
|
|
36
|
+
|
|
37
|
+
const params: OpenCameraParams = {
|
|
38
|
+
cameraFacing: 'front',
|
|
39
|
+
quality: 'high',
|
|
40
|
+
customQuality: {size: 2000, compression: 0.9},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const file = await result.current.openCamera(params)
|
|
44
|
+
|
|
45
|
+
expect(mockOpenCamera).toHaveBeenCalledWith(params)
|
|
46
|
+
expect(file).toBe(mockFile)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('calls context openCamera with default params when none provided', async () => {
|
|
50
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
51
|
+
mockOpenCamera.mockResolvedValue(mockFile)
|
|
52
|
+
|
|
53
|
+
const {result} = renderHook(() => useImagePicker())
|
|
54
|
+
|
|
55
|
+
const file = await result.current.openCamera()
|
|
56
|
+
|
|
57
|
+
expect(mockOpenCamera).toHaveBeenCalledWith({
|
|
58
|
+
cameraFacing: undefined,
|
|
59
|
+
quality: undefined,
|
|
60
|
+
customQuality: undefined,
|
|
61
|
+
})
|
|
62
|
+
expect(file).toBe(mockFile)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('supports partial params', async () => {
|
|
66
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
67
|
+
mockOpenCamera.mockResolvedValue(mockFile)
|
|
68
|
+
|
|
69
|
+
const {result} = renderHook(() => useImagePicker())
|
|
70
|
+
|
|
71
|
+
const file = await result.current.openCamera({quality: 'low'})
|
|
72
|
+
|
|
73
|
+
expect(mockOpenCamera).toHaveBeenCalledWith({
|
|
74
|
+
cameraFacing: undefined,
|
|
75
|
+
quality: 'low',
|
|
76
|
+
customQuality: undefined,
|
|
77
|
+
})
|
|
78
|
+
expect(file).toBe(mockFile)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('openGallery', () => {
|
|
83
|
+
it('calls context openGallery with provided params', async () => {
|
|
84
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
85
|
+
mockOpenGallery.mockResolvedValue(mockFile)
|
|
86
|
+
|
|
87
|
+
const {result} = renderHook(() => useImagePicker())
|
|
88
|
+
|
|
89
|
+
const params: OpenGalleryParams = {
|
|
90
|
+
quality: 'medium',
|
|
91
|
+
customQuality: {size: 1500, compression: 0.8},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const file = await result.current.openGallery(params)
|
|
95
|
+
|
|
96
|
+
expect(mockOpenGallery).toHaveBeenCalledWith(params)
|
|
97
|
+
expect(file).toBe(mockFile)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('calls context openGallery with default params when none provided', async () => {
|
|
101
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
102
|
+
mockOpenGallery.mockResolvedValue(mockFile)
|
|
103
|
+
|
|
104
|
+
const {result} = renderHook(() => useImagePicker())
|
|
105
|
+
|
|
106
|
+
const file = await result.current.openGallery()
|
|
107
|
+
|
|
108
|
+
expect(mockOpenGallery).toHaveBeenCalledWith({
|
|
109
|
+
quality: undefined,
|
|
110
|
+
customQuality: undefined,
|
|
111
|
+
})
|
|
112
|
+
expect(file).toBe(mockFile)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('supports partial params', async () => {
|
|
116
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
117
|
+
mockOpenGallery.mockResolvedValue(mockFile)
|
|
118
|
+
|
|
119
|
+
const {result} = renderHook(() => useImagePicker())
|
|
120
|
+
|
|
121
|
+
const file = await result.current.openGallery({quality: 'original'})
|
|
122
|
+
|
|
123
|
+
expect(mockOpenGallery).toHaveBeenCalledWith({
|
|
124
|
+
quality: 'original',
|
|
125
|
+
customQuality: undefined,
|
|
126
|
+
})
|
|
127
|
+
expect(file).toBe(mockFile)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('Error handling', () => {
|
|
132
|
+
it('propagates errors from openCamera', async () => {
|
|
133
|
+
const error = new Error('Camera permission denied')
|
|
134
|
+
mockOpenCamera.mockRejectedValue(error)
|
|
135
|
+
|
|
136
|
+
const {result} = renderHook(() => useImagePicker())
|
|
137
|
+
|
|
138
|
+
await expect(result.current.openCamera()).rejects.toThrow(
|
|
139
|
+
'Camera permission denied'
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('propagates errors from openGallery', async () => {
|
|
144
|
+
const error = new Error('Gallery access denied')
|
|
145
|
+
mockOpenGallery.mockRejectedValue(error)
|
|
146
|
+
|
|
147
|
+
const {result} = renderHook(() => useImagePicker())
|
|
148
|
+
|
|
149
|
+
await expect(result.current.openGallery()).rejects.toThrow(
|
|
150
|
+
'Gallery access denied'
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('Quality settings', () => {
|
|
156
|
+
it('passes through all quality options for camera', async () => {
|
|
157
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
158
|
+
mockOpenCamera.mockResolvedValue(mockFile)
|
|
159
|
+
|
|
160
|
+
const {result} = renderHook(() => useImagePicker())
|
|
161
|
+
|
|
162
|
+
// Test each quality setting
|
|
163
|
+
const qualities = ['low', 'medium', 'high', 'original'] as const
|
|
164
|
+
|
|
165
|
+
for (const quality of qualities) {
|
|
166
|
+
await result.current.openCamera({quality})
|
|
167
|
+
expect(mockOpenCamera).toHaveBeenLastCalledWith({
|
|
168
|
+
cameraFacing: undefined,
|
|
169
|
+
quality,
|
|
170
|
+
customQuality: undefined,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('passes through all quality options for gallery', async () => {
|
|
176
|
+
const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
177
|
+
mockOpenGallery.mockResolvedValue(mockFile)
|
|
178
|
+
|
|
179
|
+
const {result} = renderHook(() => useImagePicker())
|
|
180
|
+
|
|
181
|
+
// Test each quality setting
|
|
182
|
+
const qualities = ['low', 'medium', 'high', 'original'] as const
|
|
183
|
+
|
|
184
|
+
for (const quality of qualities) {
|
|
185
|
+
await result.current.openGallery({quality})
|
|
186
|
+
expect(mockOpenGallery).toHaveBeenLastCalledWith({
|
|
187
|
+
quality,
|
|
188
|
+
customQuality: undefined,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -1,24 +1,43 @@
|
|
|
1
|
+
import {useCallback} from 'react'
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
|
-
CameraFacing,
|
|
3
4
|
useImagePickerContext,
|
|
5
|
+
OpenCameraParams,
|
|
6
|
+
OpenGalleryParams,
|
|
4
7
|
} from '../../providers/ImagePickerProvider'
|
|
5
8
|
|
|
6
9
|
interface UseImagePickerReturns {
|
|
7
10
|
/**
|
|
8
11
|
* Opens the camera to take a photo.
|
|
9
12
|
*/
|
|
10
|
-
openCamera: (
|
|
13
|
+
openCamera: (params?: OpenCameraParams) => Promise<File>
|
|
11
14
|
/**
|
|
12
15
|
* Opens the gallery to select an image.
|
|
13
16
|
*/
|
|
14
|
-
openGallery: () => Promise<File>
|
|
17
|
+
openGallery: (params?: OpenGalleryParams) => Promise<File>
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function useImagePicker(): UseImagePickerReturns {
|
|
18
21
|
const {openCamera, openGallery} = useImagePickerContext()
|
|
19
22
|
|
|
23
|
+
const openCameraWithQuality = useCallback(
|
|
24
|
+
async ({cameraFacing, quality, customQuality}: OpenCameraParams = {}) => {
|
|
25
|
+
const file = await openCamera({cameraFacing, quality, customQuality})
|
|
26
|
+
return file
|
|
27
|
+
},
|
|
28
|
+
[openCamera]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const openGalleryWithQuality = useCallback(
|
|
32
|
+
async ({quality, customQuality}: OpenGalleryParams = {}) => {
|
|
33
|
+
const file = await openGallery({quality, customQuality})
|
|
34
|
+
return file
|
|
35
|
+
},
|
|
36
|
+
[openGallery]
|
|
37
|
+
)
|
|
38
|
+
|
|
20
39
|
return {
|
|
21
|
-
openCamera,
|
|
22
|
-
openGallery,
|
|
40
|
+
openCamera: openCameraWithQuality,
|
|
41
|
+
openGallery: openGalleryWithQuality,
|
|
23
42
|
}
|
|
24
43
|
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {describe, expect, it, vi, beforeEach} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {resizeImage} from './resizeImage'
|
|
4
|
+
|
|
5
|
+
describe('resizeImage', () => {
|
|
6
|
+
let mockCanvas: any
|
|
7
|
+
let mockContext: any
|
|
8
|
+
let mockImage: any
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Mock canvas context
|
|
12
|
+
mockContext = {
|
|
13
|
+
drawImage: vi.fn(),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Mock canvas
|
|
17
|
+
mockCanvas = {
|
|
18
|
+
width: 0,
|
|
19
|
+
height: 0,
|
|
20
|
+
getContext: vi.fn(() => mockContext),
|
|
21
|
+
toBlob: vi.fn(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mock document.createElement
|
|
25
|
+
const originalCreateElement = document.createElement
|
|
26
|
+
document.createElement = vi.fn((tag: string) => {
|
|
27
|
+
if (tag === 'canvas') {
|
|
28
|
+
return mockCanvas as any
|
|
29
|
+
}
|
|
30
|
+
return originalCreateElement(tag)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Mock URL methods
|
|
34
|
+
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
|
|
35
|
+
global.URL.revokeObjectURL = vi.fn()
|
|
36
|
+
|
|
37
|
+
// Mock Image constructor
|
|
38
|
+
mockImage = {
|
|
39
|
+
width: 3000,
|
|
40
|
+
height: 4000,
|
|
41
|
+
onload: null as any,
|
|
42
|
+
onerror: null as any,
|
|
43
|
+
src: '',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
global.Image = vi.fn(() => mockImage) as any
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('Quality settings', () => {
|
|
50
|
+
it('returns original file for "original" quality', async () => {
|
|
51
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
52
|
+
|
|
53
|
+
const result = await resizeImage({
|
|
54
|
+
file: originalFile,
|
|
55
|
+
quality: 'original',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(result).toBe(originalFile)
|
|
59
|
+
expect(global.Image).not.toHaveBeenCalled()
|
|
60
|
+
expect(document.createElement).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('resizes to 1080px for low quality', async () => {
|
|
64
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
65
|
+
|
|
66
|
+
// Set up canvas toBlob to call callback
|
|
67
|
+
mockCanvas.toBlob.mockImplementation((callback: any) => {
|
|
68
|
+
const blob = new Blob(['resized'], {type: 'image/jpeg'})
|
|
69
|
+
callback(blob)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Trigger image load after src is set
|
|
73
|
+
global.Image = vi.fn(() => {
|
|
74
|
+
const img = mockImage
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if (img.onload) img.onload()
|
|
77
|
+
}, 0)
|
|
78
|
+
return img
|
|
79
|
+
}) as any
|
|
80
|
+
|
|
81
|
+
const resultPromise = resizeImage({
|
|
82
|
+
file: originalFile,
|
|
83
|
+
quality: 'low',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
87
|
+
|
|
88
|
+
const result = await resultPromise
|
|
89
|
+
|
|
90
|
+
expect(result).toBeInstanceOf(File)
|
|
91
|
+
expect(result.type).toBe('image/jpeg')
|
|
92
|
+
expect(result.name).toBe('test.jpg')
|
|
93
|
+
|
|
94
|
+
// Check canvas dimensions were set correctly (maintaining aspect ratio)
|
|
95
|
+
expect(mockCanvas.width).toBe(810) // 1080 * (3000/4000)
|
|
96
|
+
expect(mockCanvas.height).toBe(1080)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('resizes to 1600px for medium quality', async () => {
|
|
100
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
101
|
+
|
|
102
|
+
mockCanvas.toBlob.mockImplementation((callback: any) => {
|
|
103
|
+
const blob = new Blob(['resized'], {type: 'image/jpeg'})
|
|
104
|
+
callback(blob)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
global.Image = vi.fn(() => {
|
|
108
|
+
const img = mockImage
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
if (img.onload) img.onload()
|
|
111
|
+
}, 0)
|
|
112
|
+
return img
|
|
113
|
+
}) as any
|
|
114
|
+
|
|
115
|
+
const result = await resizeImage({
|
|
116
|
+
file: originalFile,
|
|
117
|
+
quality: 'medium',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(result).toBeInstanceOf(File)
|
|
121
|
+
expect(mockCanvas.width).toBe(1200) // 1600 * (3000/4000)
|
|
122
|
+
expect(mockCanvas.height).toBe(1600)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('resizes to 2048px for high quality', async () => {
|
|
126
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
127
|
+
|
|
128
|
+
mockCanvas.toBlob.mockImplementation((callback: any) => {
|
|
129
|
+
const blob = new Blob(['resized'], {type: 'image/jpeg'})
|
|
130
|
+
callback(blob)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
global.Image = vi.fn(() => {
|
|
134
|
+
const img = mockImage
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
if (img.onload) img.onload()
|
|
137
|
+
}, 0)
|
|
138
|
+
return img
|
|
139
|
+
}) as any
|
|
140
|
+
|
|
141
|
+
const result = await resizeImage({
|
|
142
|
+
file: originalFile,
|
|
143
|
+
quality: 'high',
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(result).toBeInstanceOf(File)
|
|
147
|
+
expect(mockCanvas.width).toBe(1536) // 2048 * (3000/4000)
|
|
148
|
+
expect(mockCanvas.height).toBe(2048)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('Custom quality', () => {
|
|
153
|
+
it('uses custom size when provided', async () => {
|
|
154
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
155
|
+
|
|
156
|
+
mockCanvas.toBlob.mockImplementation((callback: any) => {
|
|
157
|
+
const blob = new Blob(['resized'], {type: 'image/jpeg'})
|
|
158
|
+
callback(blob)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
global.Image = vi.fn(() => {
|
|
162
|
+
const img = mockImage
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
if (img.onload) img.onload()
|
|
165
|
+
}, 0)
|
|
166
|
+
return img
|
|
167
|
+
}) as any
|
|
168
|
+
|
|
169
|
+
const result = await resizeImage({
|
|
170
|
+
file: originalFile,
|
|
171
|
+
quality: 'low',
|
|
172
|
+
customQuality: {size: 500, compression: 0.6},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(result).toBeInstanceOf(File)
|
|
176
|
+
expect(mockCanvas.width).toBe(375) // 500 * (3000/4000)
|
|
177
|
+
expect(mockCanvas.height).toBe(500)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('Aspect ratio handling', () => {
|
|
182
|
+
it('maintains aspect ratio for landscape images', async () => {
|
|
183
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
184
|
+
|
|
185
|
+
// Set landscape dimensions
|
|
186
|
+
mockImage.width = 4000
|
|
187
|
+
mockImage.height = 3000
|
|
188
|
+
|
|
189
|
+
mockCanvas.toBlob.mockImplementation((callback: any) => {
|
|
190
|
+
const blob = new Blob(['resized'], {type: 'image/jpeg'})
|
|
191
|
+
callback(blob)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
global.Image = vi.fn(() => {
|
|
195
|
+
const img = mockImage
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
if (img.onload) img.onload()
|
|
198
|
+
}, 0)
|
|
199
|
+
return img
|
|
200
|
+
}) as any
|
|
201
|
+
|
|
202
|
+
await resizeImage({
|
|
203
|
+
file: originalFile,
|
|
204
|
+
quality: 'low',
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
expect(mockCanvas.width).toBe(1080)
|
|
208
|
+
expect(mockCanvas.height).toBe(810) // 1080 * (3000/4000)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('does not resize if image is smaller than target size', async () => {
|
|
212
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
213
|
+
|
|
214
|
+
// Set small dimensions
|
|
215
|
+
mockImage.width = 800
|
|
216
|
+
mockImage.height = 600
|
|
217
|
+
|
|
218
|
+
mockCanvas.toBlob.mockImplementation((callback: any) => {
|
|
219
|
+
const blob = new Blob(['resized'], {type: 'image/jpeg'})
|
|
220
|
+
callback(blob)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
global.Image = vi.fn(() => {
|
|
224
|
+
const img = mockImage
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
if (img.onload) img.onload()
|
|
227
|
+
}, 0)
|
|
228
|
+
return img
|
|
229
|
+
}) as any
|
|
230
|
+
|
|
231
|
+
await resizeImage({
|
|
232
|
+
file: originalFile,
|
|
233
|
+
quality: 'high',
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Should maintain original dimensions
|
|
237
|
+
expect(mockCanvas.width).toBe(800)
|
|
238
|
+
expect(mockCanvas.height).toBe(600)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('Error handling', () => {
|
|
243
|
+
it('rejects when image fails to load', async () => {
|
|
244
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
245
|
+
|
|
246
|
+
global.Image = vi.fn(() => {
|
|
247
|
+
const img = mockImage
|
|
248
|
+
setTimeout(() => {
|
|
249
|
+
if (img.onerror) img.onerror()
|
|
250
|
+
}, 0)
|
|
251
|
+
return img
|
|
252
|
+
}) as any
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
resizeImage({
|
|
256
|
+
file: originalFile,
|
|
257
|
+
quality: 'medium',
|
|
258
|
+
})
|
|
259
|
+
).rejects.toThrow('Failed to load image')
|
|
260
|
+
|
|
261
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('Resource cleanup', () => {
|
|
266
|
+
it('revokes object URL after successful resize', async () => {
|
|
267
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
268
|
+
|
|
269
|
+
mockCanvas.toBlob.mockImplementation((callback: any) => {
|
|
270
|
+
const blob = new Blob(['resized'], {type: 'image/jpeg'})
|
|
271
|
+
callback(blob)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
global.Image = vi.fn(() => {
|
|
275
|
+
const img = mockImage
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
if (img.onload) img.onload()
|
|
278
|
+
}, 0)
|
|
279
|
+
return img
|
|
280
|
+
}) as any
|
|
281
|
+
|
|
282
|
+
await resizeImage({
|
|
283
|
+
file: originalFile,
|
|
284
|
+
quality: 'medium',
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
expect(URL.createObjectURL).toHaveBeenCalledWith(originalFile)
|
|
288
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('revokes object URL even when error occurs', async () => {
|
|
292
|
+
const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
293
|
+
|
|
294
|
+
global.Image = vi.fn(() => {
|
|
295
|
+
const img = mockImage
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
if (img.onerror) img.onerror()
|
|
298
|
+
}, 0)
|
|
299
|
+
return img
|
|
300
|
+
}) as any
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
await resizeImage({
|
|
304
|
+
file: originalFile,
|
|
305
|
+
quality: 'medium',
|
|
306
|
+
})
|
|
307
|
+
} catch {
|
|
308
|
+
// Expected to throw
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
})
|