Skip to content

Commit 930c483

Browse files
authored
Merge pull request #58 from frontend-opensource-project/URH-71/use-image-pre-setup
[URH-71] useImagePreSetup 신규
2 parents cb09261 + 10eaa0e commit 930c483

File tree

4 files changed

+346
-0
lines changed

4 files changed

+346
-0
lines changed

src/hooks/useImagePreSetup.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { act, renderHook, waitFor } from '@testing-library/react';
2+
import useImagePreSetup, {
3+
convertFile,
4+
DEFAULT_WEBP_QUALITY,
5+
urlFromFileHandler,
6+
validateWebPQuality,
7+
} from './useImagePreSetup';
8+
9+
import { imgTo } from '../utils';
10+
11+
jest.mock('../utils/imgTo');
12+
13+
describe('useImagePreSetup', () => {
14+
let consoleWarnSpy: jest.SpyInstance;
15+
let consoleErrorSpy: jest.SpyInstance;
16+
let mockFiles: File[];
17+
const mockUrls: string[] = ['mock-url1', 'mock-url2', 'mock-url3'];
18+
19+
beforeEach(() => {
20+
mockFiles = [
21+
new File(['content1'], 'example1.png', { type: 'image/png' }),
22+
new File(['content2'], 'example2.png', { type: 'image/jpeg' }),
23+
new File(['content3'], 'example3.png', { type: 'image/gif' }),
24+
];
25+
26+
global.URL.createObjectURL = jest.fn(() => mockUrls.shift() || 'mock-url');
27+
global.URL.revokeObjectURL = jest.fn();
28+
29+
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
30+
});
31+
32+
afterEach(() => {
33+
jest.clearAllMocks();
34+
consoleWarnSpy.mockRestore();
35+
});
36+
37+
test('파일이 주어지면 변환함수가 올바르게 호출되고 로딩 상태가 정상적으로 변경된다.', async () => {
38+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
39+
(imgTo as jest.Mock).mockImplementation((_url: string) => {
40+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
41+
return (_type: string) => {
42+
return Promise.resolve(new Blob());
43+
};
44+
});
45+
46+
const { result } = renderHook(() =>
47+
useImagePreSetup({ imageFiles: mockFiles, convertToWebP: true })
48+
);
49+
50+
expect(result.current.isLoading).toBe(true);
51+
52+
await waitFor(() => {
53+
expect(imgTo).toHaveBeenCalledTimes(mockFiles.length);
54+
});
55+
56+
await waitFor(() => {
57+
expect(result.current.isLoading).toBe(false);
58+
});
59+
60+
expect(imgTo).toHaveBeenCalledTimes(mockFiles.length);
61+
expect(result.current.isError).toBe(false);
62+
expect(result.current.previewUrls.length).toBe(mockFiles.length);
63+
expect(result.current.webpImages.length).toBe(mockFiles.length);
64+
});
65+
66+
test('파일이 주어졌을 때, URL이 생성되어 반환된다.', async () => {
67+
for (const file of mockFiles) {
68+
const result = await urlFromFileHandler(file);
69+
70+
expect(global.URL.createObjectURL).toHaveBeenCalledWith(file);
71+
72+
expect(result).toEqual({
73+
webpBlob: null,
74+
previewUrl: 'mock-url',
75+
});
76+
}
77+
});
78+
79+
test('파일이 없거나 빈 배열이 주어지면 처리하지 않는다.', () => {
80+
const { result: resultNull } = renderHook(() =>
81+
useImagePreSetup({ imageFiles: null, convertToWebP: true })
82+
);
83+
84+
expect(resultNull.current.isLoading).toBe(false);
85+
expect(resultNull.current.isError).toBe(false);
86+
expect(resultNull.current.previewUrls).toEqual([]);
87+
expect(resultNull.current.webpImages).toEqual([]);
88+
89+
const { result: resultEmpty } = renderHook(() =>
90+
useImagePreSetup({ imageFiles: [], convertToWebP: true })
91+
);
92+
93+
expect(resultEmpty.current.isLoading).toBe(false);
94+
expect(resultEmpty.current.isError).toBe(false);
95+
expect(resultEmpty.current.previewUrls).toEqual([]);
96+
expect(resultEmpty.current.webpImages).toEqual([]);
97+
});
98+
99+
test('컴포넌트 언마운트 시 URL이 적절히 해제된다.', async () => {
100+
const { unmount } = renderHook(() =>
101+
useImagePreSetup({ imageFiles: mockFiles, convertToWebP: true })
102+
);
103+
104+
expect(global.URL.revokeObjectURL).not.toHaveBeenCalled();
105+
106+
act(() => {
107+
unmount();
108+
});
109+
110+
mockUrls.forEach((url) => {
111+
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith(url);
112+
});
113+
expect(global.URL.revokeObjectURL).toHaveBeenCalledTimes(mockUrls.length);
114+
});
115+
116+
test('convertToWebP가 false일 때 경고가 출력된다.', () => {
117+
const webPQuality = 0.5;
118+
119+
validateWebPQuality(false, webPQuality);
120+
121+
expect(consoleWarnSpy).toHaveBeenCalledWith(
122+
'webPQuality`는 WebP로의 변환 품질을 설정하는 옵션입니다. `convertToWebP`를 true로 설정해야만 `webPQuality`가 적용됩니다.'
123+
);
124+
});
125+
126+
test('유효 범위를 벗어난 webPQuality 값이 주어졌을 때, 이 값이 반환된다.', () => {
127+
const validWebPQuality = 1.5;
128+
129+
const result = validateWebPQuality(true, validWebPQuality);
130+
131+
expect(result).toBe(0.8);
132+
expect(consoleWarnSpy).toHaveBeenCalledWith(
133+
`webPQuality 값이 유효 범위(0 ~ 1)를 벗어나 기본값(${DEFAULT_WEBP_QUALITY})이 사용됩니다.`
134+
);
135+
});
136+
137+
test('convertHandler가 실패할 경우, console.error가 호출되고 올바른 반환값을 가지는지 검증한다.', async () => {
138+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
139+
140+
const mockFile = new File(['content'], 'example.png', {
141+
type: 'image/png',
142+
});
143+
144+
const failingHandler = jest
145+
.fn()
146+
.mockRejectedValue(new Error('Conversion error'));
147+
148+
const result = await convertFile(mockFile, failingHandler, 0);
149+
150+
expect(consoleErrorSpy).toHaveBeenCalledWith(
151+
'1번째 파일 처리 중 오류 발생:',
152+
new Error('Conversion error')
153+
);
154+
155+
expect(result).toEqual({ webpBlob: null, previewUrl: null });
156+
157+
consoleErrorSpy.mockRestore();
158+
});
159+
});

src/hooks/useImagePreSetup.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useEffect, useState } from 'react';
2+
import { imgTo } from '../utils';
3+
4+
interface UseImagePreSetupProps {
5+
imageFiles: File[] | null;
6+
convertToWebP?: boolean;
7+
webPQuality?: number;
8+
}
9+
10+
interface UseImagePreSetupReturns {
11+
previewUrls: (string | null)[];
12+
isLoading: boolean;
13+
isError: boolean;
14+
webpImages: (Blob | null)[];
15+
originalImages: File[] | null;
16+
}
17+
18+
export interface ProcessedFileResult {
19+
webpBlob: Blob | null;
20+
previewUrl: string | null;
21+
}
22+
23+
export const convertFile = async (
24+
file: File,
25+
convertHandler: (file: File) => Promise<ProcessedFileResult>,
26+
idx: number
27+
): Promise<ProcessedFileResult> => {
28+
try {
29+
return await convertHandler(file);
30+
} catch (error) {
31+
console.error(`${idx + 1}번째 파일 처리 중 오류 발생:`, error);
32+
return { webpBlob: null, previewUrl: null };
33+
}
34+
};
35+
36+
export const urlFromFileHandler = async (
37+
file: File
38+
): Promise<ProcessedFileResult> => {
39+
const url = URL.createObjectURL(file);
40+
return { webpBlob: null, previewUrl: url };
41+
};
42+
43+
export const convertToWebPHandler = async (
44+
file: File,
45+
quality: number
46+
): Promise<ProcessedFileResult> => {
47+
const url = URL.createObjectURL(file);
48+
const imgToUrl = imgTo(url);
49+
const imgToWebP = await imgToUrl('image/webp', quality);
50+
const webpUrl = URL.createObjectURL(imgToWebP);
51+
return { webpBlob: imgToWebP, previewUrl: webpUrl };
52+
};
53+
54+
export const DEFAULT_WEBP_QUALITY = 0.8;
55+
56+
export const validateWebPQuality = (
57+
convertToWebP: boolean,
58+
webPQuality: number
59+
) => {
60+
if (!convertToWebP) {
61+
if (webPQuality !== DEFAULT_WEBP_QUALITY) {
62+
console.warn(
63+
'webPQuality`는 WebP로의 변환 품질을 설정하는 옵션입니다. `convertToWebP`를 true로 설정해야만 `webPQuality`가 적용됩니다.'
64+
);
65+
}
66+
return DEFAULT_WEBP_QUALITY;
67+
}
68+
if (webPQuality < 0 || webPQuality > 1) {
69+
console.warn(
70+
`webPQuality 값이 유효 범위(0 ~ 1)를 벗어나 기본값(${DEFAULT_WEBP_QUALITY})이 사용됩니다.`
71+
);
72+
return DEFAULT_WEBP_QUALITY;
73+
}
74+
return webPQuality;
75+
};
76+
77+
/**
78+
* 이미지 파일을 처리하고 WebP 변환 또는 URL 생성을 수행하는 커스텀 훅.
79+
* @param {File[] | null} [props.imageFiles=null] - 처리할 이미지 파일의 배열.
80+
* @param {boolean} [props.convertToWebP=false] - WebP 형식으로 변환할지 여부. 기본값=false.
81+
* @param {number} [props.webPQuality=0.8] - WebP 변환 품질. 기본값=0.8. 유효 범위는 0에서 1 사이입니다.
82+
*
83+
* @returns {string[] | null[]} previewUrls - 처리된 이미지의 미리보기 URL 배열. 오류가 발생한 경우 null이 포함될 수 있음.
84+
* @returns {boolean} isLoading - 이미지 처리 진행 여부를 나타내는 상태.
85+
* @returns {boolean} isError - 이미지 처리 중 오류 발생 여부를 나타내는 상태.
86+
* @returns {Blob[] | null[]} webpImages - WebP 형식으로 변환된 이미지 Blob 배열. 변환되지 않았거나 오류가 발생한 경우 null이 포함될 수 있음.
87+
* @returns {File[] | null} originalImages - 원본 이미지 파일의 배열.
88+
*
89+
* @description
90+
* 주어진 이미지 파일을 처리하여 미리보기 URL을 생성하거나 WebP 형식으로 변환합니다.
91+
* 처리 상태를 나타내는 로딩과 오류 상태를 관리하며, 변환된 WebP 이미지와 원본 이미지 파일을 반환합니다.
92+
*/
93+
const useImagePreSetup = ({
94+
imageFiles = [],
95+
convertToWebP = false,
96+
webPQuality = DEFAULT_WEBP_QUALITY,
97+
}: UseImagePreSetupProps): UseImagePreSetupReturns => {
98+
const validatedWebPQuality = validateWebPQuality(convertToWebP, webPQuality);
99+
100+
const [isLoading, setIsLoading] = useState(false);
101+
const [isError, setIsError] = useState(false);
102+
const [previewUrls, setPreviewUrls] = useState<(string | null)[]>([]);
103+
const [webpImages, setWebpImages] = useState<(Blob | null)[]>([]);
104+
105+
const processImages = async (imageFiles: File[]) => {
106+
setIsLoading(true);
107+
setIsError(false);
108+
109+
try {
110+
const convertHandler = convertToWebP
111+
? (file: File) => convertToWebPHandler(file, validatedWebPQuality)
112+
: urlFromFileHandler;
113+
114+
const convertResults = await Promise.all(
115+
imageFiles.map((file, idx) => convertFile(file, convertHandler, idx))
116+
);
117+
118+
const previewUrlsList = convertResults.map((result) => result.previewUrl);
119+
const webpImagesList = convertResults.map((result) => result.webpBlob);
120+
121+
setPreviewUrls(previewUrlsList);
122+
setWebpImages(webpImagesList);
123+
} catch (error) {
124+
setIsError(true);
125+
} finally {
126+
setIsLoading(false);
127+
}
128+
};
129+
130+
useEffect(() => {
131+
if (!imageFiles || imageFiles.length === 0) return;
132+
133+
processImages(imageFiles);
134+
135+
return () => {
136+
previewUrls.forEach((url) => {
137+
if (url) URL.revokeObjectURL(url);
138+
});
139+
};
140+
// eslint-disable-next-line react-hooks/exhaustive-deps
141+
}, [imageFiles, convertToWebP]);
142+
143+
return {
144+
previewUrls,
145+
isLoading,
146+
isError,
147+
webpImages,
148+
originalImages: imageFiles,
149+
};
150+
};
151+
152+
export default useImagePreSetup;

src/utils/imgTo.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export const imgTo =
2+
(path: string) =>
3+
(type: string, quality?: number): Promise<Blob> => {
4+
return new Promise((resolve, reject) => {
5+
const img = new Image();
6+
7+
img.crossOrigin = 'Anonymous';
8+
9+
img.onload = () => {
10+
URL.revokeObjectURL(path);
11+
const canvas = document.createElement('canvas');
12+
const ctx = canvas.getContext('2d');
13+
canvas.width = img.naturalWidth;
14+
canvas.height = img.naturalHeight;
15+
ctx?.drawImage(img, 0, 0);
16+
canvas.toBlob(
17+
(blob) => {
18+
blob
19+
? resolve(blob)
20+
: reject(new Error('Failed to convert image to blob'));
21+
},
22+
type,
23+
quality
24+
);
25+
};
26+
27+
img.onerror = () => {
28+
URL.revokeObjectURL(path);
29+
reject(new Error('...'));
30+
};
31+
32+
img.src = path;
33+
});
34+
};

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { isClient, hasNavigator } from './isClient';
44
export { isTouchDevice } from './isTouchDevice';
55
export { validators, MatchError } from './validators';
66
export { imgToBlob } from './imgToBlob';
7+
export { imgTo } from './imgTo';

0 commit comments

Comments
 (0)