From 951ca450dcce9c05e06d8a31de67640244338d9f Mon Sep 17 00:00:00 2001 From: Majmuni Date: Mon, 25 Aug 2025 13:42:18 +0800 Subject: [PATCH 1/4] Add `useClipboard` hook with clipboard management and tests This commit introduces the `useClipboard` hook for managing clipboard data, including support for copying and pasting various types, error handling, and permission checks. Comprehensive documentation, demos, unit tests, and error management are included to ensure robustness and usability. --- .../src/useClipboard/__tests__/index.spec.ts | 200 +++++++++++++ .../hooks/src/useClipboard/demo/demo1.tsx | 145 ++++++++++ .../hooks/src/useClipboard/demo/demo2.tsx | 182 ++++++++++++ .../hooks/src/useClipboard/demo/demo3.tsx | 243 ++++++++++++++++ .../hooks/src/useClipboard/index.en-US.md | 130 +++++++++ packages/hooks/src/useClipboard/index.ts | 273 ++++++++++++++++++ .../hooks/src/useClipboard/index.zh-CN.md | 126 ++++++++ 7 files changed, 1299 insertions(+) create mode 100644 packages/hooks/src/useClipboard/__tests__/index.spec.ts create mode 100644 packages/hooks/src/useClipboard/demo/demo1.tsx create mode 100644 packages/hooks/src/useClipboard/demo/demo2.tsx create mode 100644 packages/hooks/src/useClipboard/demo/demo3.tsx create mode 100644 packages/hooks/src/useClipboard/index.en-US.md create mode 100644 packages/hooks/src/useClipboard/index.ts create mode 100644 packages/hooks/src/useClipboard/index.zh-CN.md diff --git a/packages/hooks/src/useClipboard/__tests__/index.spec.ts b/packages/hooks/src/useClipboard/__tests__/index.spec.ts new file mode 100644 index 0000000000..e5c4a64c94 --- /dev/null +++ b/packages/hooks/src/useClipboard/__tests__/index.spec.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import useClipboard from '../index'; + +// 模拟 ClipboardItem +class MockClipboardItem { + constructor(public items: Record) {} + + get types() { + return Object.keys(this.items); + } + + async getType(type: string): Promise { + const blob = this.items[type]; + if (!blob) { + throw new Error(`Type ${type} not found`); + } + return blob; + } + + static supports = vi.fn().mockReturnValue(true); +} + +// 全局模拟 + +global.ClipboardItem = MockClipboardItem as any; + +global.Blob = class MockBlob { + constructor(public content: string[], public options: { type: string }) {} + async text(): Promise { + return this.content.join(''); + } +} as any; + +// 模拟 btoa 和 atob + +global.btoa = vi.fn((str: string) => Buffer.from(str, 'binary').toString('base64')); + +global.atob = vi.fn((str: string) => Buffer.from(str, 'base64').toString('binary')); + +describe('useClipboard', () => { + let mockClipboard: { + write: ReturnType; + read: ReturnType; + readText: ReturnType; + }; + + let mockPermissions: { + query: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockClipboard = { + write: vi.fn(), + read: vi.fn(), + readText: vi.fn(), + }; + + mockPermissions = { + query: vi.fn(), + }; + + Object.defineProperty(window, 'isSecureContext', { + value: true, + configurable: true, + }); + + Object.defineProperty(window.navigator, 'clipboard', { + value: mockClipboard, + configurable: true, + }); + + Object.defineProperty(window.navigator, 'permissions', { + value: mockPermissions, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isSupported', () => { + it('should return true when clipboard API is available in secure context', () => { + const { result } = renderHook(() => useClipboard()); + expect(result.current.isSupported).toBe(true); + }); + + it('should return false when not in secure context', () => { + Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true }); + const { result } = renderHook(() => useClipboard()); + expect(result.current.isSupported).toBe(false); + }); + + it('should return false when clipboard API is not available', () => { + // 删除属性而不是设为 undefined + // @ts-expect-error + delete window.navigator.clipboard; + const { result } = renderHook(() => useClipboard()); + expect(result.current.isSupported).toBe(false); + }); + }); + + describe('hasPermission', () => { + it('should return true when both read and write permissions are granted', async () => { + mockPermissions.query.mockResolvedValueOnce({ state: 'granted' }); + mockPermissions.query.mockResolvedValueOnce({ state: 'granted' }); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(true); + }); + }); + + it('should return false when write permission is denied', async () => { + mockPermissions.query.mockResolvedValueOnce({ state: 'denied' }); + mockPermissions.query.mockResolvedValueOnce({ state: 'granted' }); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(false); + }); + }); + + it('should fallback to readText test when permissions API fails', async () => { + mockPermissions.query.mockRejectedValue(new Error('Permissions API not available')); + mockClipboard.readText.mockResolvedValue('test'); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(true); + }); + }); + + it('should return false when not supported', async () => { + // @ts-expect-error + delete window.navigator.clipboard; + // @ts-expect-error + delete window.navigator.permissions; + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(false); + }); + }); + }); + + describe('copy', () => { + it('should successfully copy simple data with HTML support', async () => { + mockClipboard.write.mockResolvedValue(undefined); + MockClipboardItem.supports.mockReturnValue(true); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + await result.current.copy('Test display text', { id: 1 }); + }); + expect(mockClipboard.write).toHaveBeenCalled(); + }); + + it('should throw error when not supported', async () => { + // @ts-expect-error + delete window.navigator.clipboard; + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + await expect(result.current.copy('test', { data: 'test' })) + .rejects.toThrow('Clipboard API is not supported in this environment'); + }); + }); + }); + + describe('paste', () => { + it('should return null when clipboard is empty', async () => { + mockClipboard.read.mockResolvedValue([]); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const pasted = await result.current.paste(); + expect(pasted).toBeNull(); + }); + }); + + it('should throw error when not supported', async () => { + // @ts-expect-error + delete window.navigator.clipboard; + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + await expect(result.current.paste()) + .rejects.toThrow('Clipboard API is not supported in this environment'); + }); + }); + }); +}); diff --git a/packages/hooks/src/useClipboard/demo/demo1.tsx b/packages/hooks/src/useClipboard/demo/demo1.tsx new file mode 100644 index 0000000000..6d62706d8b --- /dev/null +++ b/packages/hooks/src/useClipboard/demo/demo1.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { Button, Card, Input, message, Space, Typography } from 'antd'; +import { FileTextOutlined, UserOutlined } from '@ant-design/icons'; +import useClipboard from '../index'; + +const { TextArea } = Input; +const { Text } = Typography; + +interface UserInfo { + id: number; + name: string; + email: string; + age: number; + address: { + city: string; + district: string; + }; +} + +const Demo1: React.FC = () => { + const [pastedData, setPastedData] = useState(null); + const { copy, paste, isSupported } = useClipboard(); + + // 示例数据 + const userInfo: UserInfo = { + id: 1, + name: 'John Doe', + email: 'john@example.com', + age: 28, + address: { + city: 'Beijing', + district: 'Haidian District', + }, + }; + const configObject = { + theme: 'dark', + language: 'zh-CN', + features: { + clipboard: true, + notifications: false, + autoSave: true, + }, + lastModified: new Date().toISOString(), + }; + + const handleCopyData = async (data: any, displayText: string) => { + try { + await copy(displayText, data); + message.success(`${displayText} 复制成功!`); + } catch (error:any) { + message.error(`复制失败: ${error?.message}`); + } + }; + + const handlePaste = async (showDisplayText?: boolean) => { + try { + const data = showDisplayText ? await paste() : await navigator.clipboard.readText(); + setPastedData(data); + if (data) { + message.success('粘贴成功!'); + } else { + message.info('剪贴板为空'); + } + } catch (error:any) { + message.error(`粘贴失败: ${error?.message}`); + } + }; + + if (!isSupported) { + return ( +
+ Clipboard API is not supported in the current environment +
+ ); + } + + return ( +
+ + {/* 用户信息 */} + + displayText:UserInfo + + } + > +
{JSON.stringify(userInfo, null, 2)}
+ +
+ + {/* 配置对象 */} + + displayText:ConfigObject + + } + > +
{JSON.stringify(configObject, null, 2)}
+ +
+ + + + {/* 显示粘贴的数据 */} + {pastedData && ( + +
+ Data Type: + {typeof pastedData} +
+