Skip to content

Commit 0576f59

Browse files
authored
Merge pull request #37 from klmhyeonwoo/feature/utils-workspace-yeom
feat: copyToClipboard util function
2 parents 721d3ae + 8831a28 commit 0576f59

File tree

7 files changed

+154
-229
lines changed

7 files changed

+154
-229
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const notEmpty = commonUtil.isEmpty("hello"); // false
6666
const nullCheck = commonUtil.isNull(null); // true
6767
const notNull = commonUtil.isNull("hello"); // false
6868
await commonUtil.sleep(1000); // Pauses execution for 1 second
69+
const copied = await commonUtil.copyToClipboard("Hello, World!"); // true if successful
6970

7071
// Cookie utilities
7172
cookieUtil.setCookie("theme", "dark");
@@ -110,6 +111,7 @@ const formattedPhone = formatUtil.formatPhoneNumber("01012345678"); // "010-1234
110111
- `isEmpty(value: unknown): boolean` - Checks if a value is empty (null, undefined, "", 0, [], {}, empty Set/Map, NaN, or invalid Date)
111112
- `isNull(value: unknown): value is null` - Type guard that checks if a value is null and narrows the type
112113
- `sleep(ms: number): Promise<void>` - Pauses execution for a specified number of milliseconds
114+
- `copyToClipboard(text: string): Promise<boolean>` - Copies text to the user's clipboard. Uses modern Clipboard API with fallback to legacy execCommand method. Returns true if successful, false if failed.
113115

114116
### CookieUtil
115117

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, test, vi, beforeEach } from "vitest";
2+
import copyToClipboard from ".";
3+
4+
// * 브라우저 API를 모킹
5+
// * 실제로는 아무것도 하지 않지만, 호출 여부를 추적할 수 있는 가짜 함수
6+
const mockClipboard = {
7+
writeText: vi.fn(),
8+
};
9+
10+
vi.stubGlobal("navigator", {
11+
clipboard: mockClipboard,
12+
});
13+
14+
const mockElement = {
15+
value: "",
16+
style: {},
17+
select: vi.fn(),
18+
focus: vi.fn(),
19+
};
20+
21+
vi.stubGlobal("document", {
22+
execCommand: vi.fn(),
23+
body: {
24+
appendChild: vi.fn(),
25+
removeChild: vi.fn(),
26+
},
27+
createElement: vi.fn(() => mockElement),
28+
});
29+
30+
describe("copyToClipboard", () => {
31+
// * 각 테스트 실행 전 모킹 함수 초기화
32+
beforeEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
describe("성공 케이스", () => {
37+
test("최신 Clipboard API를 사용하여 성공적으로 복사한다", async () => {
38+
// * Clipboard API 성공 시나리오
39+
mockClipboard.writeText.mockResolvedValue(undefined);
40+
41+
const result = await copyToClipboard("hello world");
42+
43+
expect(result).toBe(true);
44+
expect(mockClipboard.writeText).toHaveBeenCalledTimes(1);
45+
expect(mockClipboard.writeText).toHaveBeenCalledWith("hello world");
46+
47+
// * 대체(레거시) 방식은 호출되지 않아야 함
48+
expect(document.execCommand).not.toHaveBeenCalled();
49+
});
50+
51+
test("Clipboard API 실패 시, 레거시 execCommand 방식으로 대체하여 성공적으로 복사한다", async () => {
52+
// * Clipboard API 실패, execCommand 성공 시나리오
53+
mockClipboard.writeText.mockRejectedValue(new Error("API not available"));
54+
vi.mocked(document.execCommand).mockReturnValue(true);
55+
56+
const result = await copyToClipboard("fallback test");
57+
58+
expect(result).toBe(true);
59+
expect(mockClipboard.writeText).toHaveBeenCalledTimes(1);
60+
expect(document.execCommand).toHaveBeenCalledWith("copy");
61+
});
62+
});
63+
64+
describe("실패 및 예외 케이스", () => {
65+
test("두 가지 방식 모두 실패하면 false를 반환한다", async () => {
66+
// * 두 방식 모두 실패 시나리오
67+
mockClipboard.writeText.mockRejectedValue(new Error("API not available"));
68+
vi.mocked(document.execCommand).mockReturnValue(false);
69+
70+
const result = await copyToClipboard("fail test");
71+
72+
expect(result).toBe(false);
73+
expect(mockClipboard.writeText).toHaveBeenCalledTimes(1);
74+
expect(document.execCommand).toHaveBeenCalledWith("copy");
75+
});
76+
77+
test("빈 문자열도 성공적으로 복사한다", async () => {
78+
mockClipboard.writeText.mockResolvedValue(undefined);
79+
80+
const result = await copyToClipboard("");
81+
82+
expect(result).toBe(true);
83+
expect(mockClipboard.writeText).toHaveBeenCalledWith("");
84+
});
85+
86+
test("매우 긴 텍스트가 주어지면 에러를 던지고 대체(fallback) 로직으로 복사를 시도한다", async () => {
87+
const longText = "a".repeat(1000001);
88+
vi.mocked(document.execCommand).mockReturnValue(true);
89+
90+
const result = await copyToClipboard(longText);
91+
92+
expect(result).toBe(true);
93+
// * 크기 제한으로 Clipboard API는 호출되지 않음
94+
expect(mockClipboard.writeText).not.toHaveBeenCalled();
95+
// * execCommand로 대체 처리
96+
expect(document.execCommand).toHaveBeenCalledWith("copy");
97+
});
98+
});
99+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* 주어진 텍스트를 사용자의 클립보드에 비동기적으로 복사합니다.
3+
*
4+
* 최신 Clipboard API (`navigator.clipboard.writeText`)를 우선적으로 시도하며,
5+
* 실패하거나 사용할 수 없는 경우 레거시 `document.execCommand('copy')` 방식으로 대체 수행합니다.
6+
*
7+
* @param {string} text - 클립보드에 복사할 문자열
8+
* @returns {Promise<boolean>} 복사 성공 시 `true`, 실패 시 `false`를 반환
9+
*/
10+
export default async function copyToClipboard(text: string): Promise<boolean> {
11+
try {
12+
if (text.length > 1000000) {
13+
throw new Error("Text too large to copy");
14+
}
15+
16+
await navigator.clipboard.writeText(text);
17+
18+
return true;
19+
} catch (error) {
20+
const textArea = document.createElement("textarea");
21+
textArea.value = text;
22+
23+
textArea.style.position = "fixed";
24+
textArea.style.top = "-9999px";
25+
textArea.style.left = "-9999px";
26+
27+
document.body.appendChild(textArea);
28+
textArea.focus();
29+
textArea.select();
30+
31+
try {
32+
const successful = document.execCommand("copy");
33+
document.body.removeChild(textArea);
34+
35+
return successful;
36+
} catch (execError) {
37+
document.body.removeChild(textArea);
38+
39+
return false;
40+
}
41+
}
42+
}

package/commonUtil/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { default as isEmpty } from "./isEmpty";
22
export { default as isNull } from "./isNull";
3+
export { default as sleep } from "./sleep";
4+
export { default as copyToClipboard } from "./copyToClipboard";

package/commonUtil/sleep/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2-
import { sleep } from ".";
2+
import sleep from ".";
33

44
describe("sleep", () => {
55
// 각 'test'가 실행되기 전에 가짜 타이머를 활성화합니다.

package/commonUtil/sleep/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* @param ms 지연시킬 시간 (밀리초 단위)
55
* @returns {Promise<void>} 시간이 지나면 resolve되는 Promise
66
*/
7-
export function sleep(ms: number): Promise<void> {
7+
export default function sleep(ms: number): Promise<void> {
88
const delay = Math.max(0, ms | 0);
99
return new Promise((resolve) => setTimeout(resolve, delay));
1010
}

0 commit comments

Comments
 (0)