diff --git a/jest.config.ts b/jest.config.ts index e307cf1..c3d92c1 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -9,6 +9,7 @@ const config: JestConfigWithTsJest = { moduleNameMapper: { '\\.(css|less|sass|scss)$': 'identity-obj-proxy', }, + setupFiles: ['jest-canvas-mock'], setupFilesAfterEnv: ['./jest.setup.ts'], }; diff --git a/package-lock.json b/package-lock.json index 9093074..bab9d65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "husky": "^9.1.4", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", + "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.7", "prettier": "^3.3.2", @@ -4728,6 +4729,12 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "node_modules/cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -7108,6 +7115,16 @@ } } }, + "node_modules/jest-canvas-mock": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz", + "integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==", + "dev": true, + "dependencies": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "node_modules/jest-changed-files": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", @@ -9502,6 +9519,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "dependencies": { + "color-name": "^1.1.4" + } + }, + "node_modules/moo-color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -15429,6 +15461,12 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true + }, "cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -17099,6 +17137,16 @@ "jest-cli": "^29.7.0" } }, + "jest-canvas-mock": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz", + "integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==", + "dev": true, + "requires": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "jest-changed-files": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", @@ -18900,6 +18948,23 @@ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true }, + "moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "requires": { + "color-name": "^1.1.4" + }, + "dependencies": { + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index a3384df..14262a5 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "husky": "^9.1.4", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", + "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.7", "prettier": "^3.3.2", diff --git a/src/hooks/useClipboard.test.ts b/src/hooks/useClipboard.test.ts new file mode 100644 index 0000000..22914db --- /dev/null +++ b/src/hooks/useClipboard.test.ts @@ -0,0 +1,74 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import useClipboard from './useClipboard'; + +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + write: jest.fn(), + }, +}); + +describe('useClipboard', () => { + beforeEach(() => { + const { result } = renderHook(() => useClipboard()); + result.current.copied = false; + jest.useFakeTimers(); + }); + + test('copyText() 함수를 호출하면 클립보드에 텍스트가 저장된다.', async () => { + const spy = jest.spyOn(navigator.clipboard, 'writeText'); + const { result } = renderHook(() => useClipboard()); + const { copyText } = result.current; + const text = 'Text'; + + act(() => { + copyText(text); + }); + + expect(spy).toHaveBeenCalledWith(text); + await waitFor(() => { + expect(result.current.copied).toBe(true); + }); + }); + + // test('copyImg() 함수를 호출하면 클립보드에 이미지가 저장된다.', async () => { + // const spy = jest.spyOn(navigator.clipboard, 'write'); + // const { result } = renderHook(() => useClipboard()); + // const { copyImg } = result.current; + // const path = + // 'https://avatars.githubusercontent.com/u/173591906?s=400&u=4083b40d445144ec5f214b5d2d7efbd5471f3f97&v=4'; + + // act(() => { + // copyImg(path); + // }); + + // expect(spy).toHaveBeenCalled(); + // await waitFor(() => { + // expect(result.current.copied).toBe(true); + // }); + // }); + + test('copied 상태가 true로 변경된 이후 5초가 지나면 다시 false로 변경된다.', async () => { + const spy = jest.spyOn(navigator.clipboard, 'writeText'); + const { result } = renderHook(() => useClipboard()); + const { copyText } = result.current; + const text = 'Text'; + + act(() => { + copyText(text); + }); + + expect(spy).toHaveBeenCalledWith(text); + await waitFor(() => { + expect(result.current.copied).toBe(true); + }); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + await waitFor(() => { + expect(result.current.copied).toBe(false); + }); + }); +}); diff --git a/src/hooks/useClipboard.ts b/src/hooks/useClipboard.ts new file mode 100644 index 0000000..62359bd --- /dev/null +++ b/src/hooks/useClipboard.ts @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react'; +import { imgToBlob } from '../utils'; + +interface UseClipboardProps { + resetTime?: number; +} + +interface UseClipboardReturns { + copied: boolean; + copyText: (text: string) => void; + copyImg: (path: string) => void; +} + +/** + * 클립보드에 텍스트나 이미지를 복사하는 커스텀 훅 + * + * @param {number} [resetTime=5000] - 복사 작업이 완료된 후 플래그를 리셋할 시간(ms) + * + * @returns + * - `copied`: 복사 작업의 성공 여부를 나타내는 플래그 + * - `copyText`: 텍스트를 클립보드에 복사하는 비동기 함수 + * - `copyImg`: 주어진 경로의 이미지를 클립보드에 복사하는 함수 + * + * @description + * 클립보드 API가 지원되지 않는 경우 에러를 발생시킵니다. + */ +const useClipboard = ({ + resetTime = 5000, +}: UseClipboardProps = {}): UseClipboardReturns => { + const [copied, setCopied] = useState(false); + + if (!navigator.clipboard) { + throw new Error('Clipboard API not supported.'); + } + + const handleCopy = async (copy: () => Promise) => { + setCopied(false); + try { + await copy(); + setCopied(true); + } catch (error) { + console.error(error); + throw new Error(`Failed to save to clipboard.`); + } + }; + + const copyText = async (text: string) => { + await handleCopy(() => navigator.clipboard.writeText(text)); + }; + + const copyImg = async (path: string) => { + const imgBlob = await imgToBlob(path); + await handleCopy(() => + navigator.clipboard.write([new ClipboardItem({ 'image/png': imgBlob })]) + ); + }; + + useEffect(() => { + if (copied) { + const timer = setTimeout(() => { + setCopied(false); + }, resetTime); + return () => clearTimeout(timer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [copied]); + + return { copied, copyText, copyImg }; +}; + +export default useClipboard; diff --git a/src/utils/imgToBlob.ts b/src/utils/imgToBlob.ts new file mode 100644 index 0000000..8b10dcf --- /dev/null +++ b/src/utils/imgToBlob.ts @@ -0,0 +1,22 @@ +const img = new Image(); +const canvas = document.createElement('canvas'); +const ctx = canvas.getContext('2d'); + +export const imgToBlob = (path: string): Promise => { + return new Promise((resolve, reject) => { + img.crossOrigin = 'Anonymous'; + img.onload = function () { + if (this instanceof HTMLImageElement) { + canvas.width = this.naturalWidth; + canvas.height = this.naturalHeight; + ctx?.drawImage(this, 0, 0); + canvas.toBlob((blob) => { + blob + ? resolve(blob) + : reject(new Error('Failed to convert image to blob')); + }, 'image/png'); + } + }; + img.src = path; + }); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index ff44333..4beb62b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export { delayExecution, type CancelToken } from './delayExecution'; export { throttle } from './throttle'; export { isClient } from './isClient'; export { isTouchDevice } from './isTouchDevice'; +export { imgToBlob } from './imgToBlob';