Skip to content

Commit 414e933

Browse files
committed
fix: chrome http剪切板没有权限的问题
1 parent a3dfead commit 414e933

3 files changed

Lines changed: 330 additions & 42 deletions

File tree

lib/utils/clipboard.ts

Lines changed: 283 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,218 @@
11
import { addToast } from "@heroui/toast";
2+
import ClipboardJS from 'clipboard';
23

34
/**
45
* 健壮的剪贴板复制工具
5-
* 支持现代 Clipboard API、传统 execCommand 和手动复制备选方案
6+
* 包含权限检测和用户友好的提示
67
*/
78
export class ClipboardManager {
89
/**
9-
* 现代剪贴板 API 复制方法(需要 HTTPS 或 localhost)
10+
* 检测剪贴板权限状态
11+
*/
12+
private static async checkClipboardPermission(): Promise<{
13+
granted: boolean;
14+
denied: boolean;
15+
prompt: boolean;
16+
error?: string;
17+
}> {
18+
try {
19+
// 检查 Permissions API 是否可用
20+
if (!navigator.permissions) {
21+
return { granted: false, denied: false, prompt: false, error: '浏览器不支持权限 API' };
22+
}
23+
24+
const result = await navigator.permissions.query({ name: 'clipboard-write' as PermissionName });
25+
26+
return {
27+
granted: result.state === 'granted',
28+
denied: result.state === 'denied',
29+
prompt: result.state === 'prompt'
30+
};
31+
} catch (error) {
32+
console.warn('检查剪贴板权限失败:', error);
33+
return { granted: false, denied: false, prompt: false, error: '无法检查权限状态' };
34+
}
35+
}
36+
37+
/**
38+
* 检测是否为 Chrome 浏览器的 HTTP 环境
39+
*/
40+
private static isChrome(): boolean {
41+
return /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
42+
}
43+
44+
/**
45+
* 显示权限被拒绝的友好提示
46+
*/
47+
private static showPermissionDeniedMessage(onFallback?: (text: string) => void, text?: string): void {
48+
const isChrome = ClipboardManager.isChrome();
49+
const isHttp = location.protocol === 'http:';
50+
51+
let title = '剪贴板权限被拒绝';
52+
let description = '请手动复制内容';
53+
54+
if (isChrome && isHttp) {
55+
title = 'Chrome 安全限制';
56+
description = 'HTTP 环境下剪贴板功能被限制,请切换到 HTTPS 或手动复制';
57+
}
58+
59+
addToast({
60+
title,
61+
description,
62+
color: 'warning'
63+
});
64+
65+
// 如果提供了回退函数,显示手动复制弹窗
66+
if (onFallback && text) {
67+
onFallback(text);
68+
}
69+
}
70+
71+
/**
72+
* 现代剪贴板 API 复制方法(HTTPS 或 localhost 优先使用)
1073
*/
1174
private static async copyWithClipboardAPI(text: string): Promise<boolean> {
1275
try {
13-
// 更严格的检查
76+
// 检查是否支持现代剪贴板 API
1477
if (!navigator || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
1578
return false;
1679
}
1780

81+
// 检查是否在安全上下文中(HTTPS 或 localhost)
82+
if (!window.isSecureContext) {
83+
return false;
84+
}
85+
1886
await navigator.clipboard.writeText(text);
19-
return true;
87+
88+
// 验证复制是否成功
89+
try {
90+
const clipboardText = await navigator.clipboard.readText();
91+
return clipboardText === text;
92+
} catch (e) {
93+
// 如果无法读取剪贴板内容,假设复制成功
94+
return true;
95+
}
2096
} catch (error) {
2197
console.warn('Clipboard API failed:', error);
2298
return false;
2399
}
24100
}
25101

26102
/**
27-
* 传统方法作为回退方案(HTTP 环境可用)
103+
* 改进的 execCommand 方法,确保在正确的上下文中执行
104+
*/
105+
private static copyWithExecCommand(text: string): boolean {
106+
try {
107+
// 创建一个隐藏的可编辑 div
108+
const hiddenDiv = document.createElement('div');
109+
hiddenDiv.style.position = 'fixed';
110+
hiddenDiv.style.left = '-999999px';
111+
hiddenDiv.style.top = '-999999px';
112+
hiddenDiv.style.opacity = '0';
113+
hiddenDiv.style.pointerEvents = 'none';
114+
hiddenDiv.style.whiteSpace = 'pre';
115+
hiddenDiv.contentEditable = 'true';
116+
hiddenDiv.textContent = text;
117+
118+
document.body.appendChild(hiddenDiv);
119+
120+
// 选择文本
121+
const range = document.createRange();
122+
range.selectNodeContents(hiddenDiv);
123+
const selection = window.getSelection();
124+
if (!selection) {
125+
document.body.removeChild(hiddenDiv);
126+
return false;
127+
}
128+
129+
selection.removeAllRanges();
130+
selection.addRange(range);
131+
132+
// 聚焦到元素
133+
hiddenDiv.focus();
134+
135+
// 执行复制
136+
const successful = document.execCommand('copy');
137+
138+
// 清理
139+
selection.removeAllRanges();
140+
document.body.removeChild(hiddenDiv);
141+
142+
console.log('execCommand 结果:', successful);
143+
return successful;
144+
} catch (error) {
145+
console.warn('execCommand 失败:', error);
146+
return false;
147+
}
148+
}
149+
150+
/**
151+
* 使用 textarea 的备用方法
28152
*/
29-
private static copyWithLegacyAPI(text: string): boolean {
153+
private static copyWithTextarea(text: string): boolean {
30154
try {
31-
// 创建临时文本区域
32-
const textArea = document.createElement('textarea');
33-
textArea.value = text;
34-
textArea.style.position = 'fixed';
35-
textArea.style.left = '-999999px';
36-
textArea.style.top = '-999999px';
37-
document.body.appendChild(textArea);
38-
39-
textArea.focus();
40-
textArea.select();
41-
42-
// 尝试执行复制命令
155+
const textarea = document.createElement('textarea');
156+
textarea.value = text;
157+
textarea.style.position = 'fixed';
158+
textarea.style.left = '-999999px';
159+
textarea.style.top = '-999999px';
160+
textarea.style.opacity = '0';
161+
textarea.style.pointerEvents = 'none';
162+
textarea.readOnly = false;
163+
164+
document.body.appendChild(textarea);
165+
166+
// 聚焦并选择
167+
textarea.focus();
168+
textarea.select();
169+
textarea.setSelectionRange(0, text.length);
170+
171+
// 执行复制
43172
const successful = document.execCommand('copy');
44-
document.body.removeChild(textArea);
45173

174+
document.body.removeChild(textarea);
175+
176+
console.log('textarea 复制结果:', successful);
46177
return successful;
47178
} catch (error) {
48-
console.warn('Legacy copy failed:', error);
179+
console.warn('textarea 复制失败:', error);
49180
return false;
50181
}
51182
}
52183

53184
/**
54-
* 主复制方法
185+
* 检测当前环境的剪贴板支持情况
186+
*/
187+
private static async getEnvironmentInfo(): Promise<{
188+
isSecureContext: boolean;
189+
hasClipboardAPI: boolean;
190+
hasExecCommand: boolean;
191+
clipboardJSSupported: boolean;
192+
permission: {
193+
granted: boolean;
194+
denied: boolean;
195+
prompt: boolean;
196+
error?: string;
197+
};
198+
isChrome: boolean;
199+
isHttp: boolean;
200+
}> {
201+
const permission = await ClipboardManager.checkClipboardPermission();
202+
203+
return {
204+
isSecureContext: window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1',
205+
hasClipboardAPI: !!(navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function'),
206+
hasExecCommand: !!(document && typeof document.execCommand === 'function'),
207+
clipboardJSSupported: ClipboardJS.isSupported(),
208+
permission,
209+
isChrome: ClipboardManager.isChrome(),
210+
isHttp: location.protocol === 'http:'
211+
};
212+
}
213+
214+
/**
215+
* 主复制方法 - 确保在用户交互上下文中调用
55216
* @param text 要复制的文本
56217
* @param successMessage 成功时的提示消息
57218
* @param onFallback 当所有自动复制方法都失败时的回调函数
@@ -61,43 +222,123 @@ export class ClipboardManager {
61222
successMessage: string = '已复制到剪贴板',
62223
onFallback?: (text: string) => void
63224
): Promise<void> {
64-
// 首先尝试现代 API
65-
const modernSuccess = await ClipboardManager.copyWithClipboardAPI(text);
66-
if (modernSuccess) {
67-
addToast({
68-
title: '已复制',
69-
description: successMessage,
70-
color: 'success'
71-
});
225+
const envInfo = await ClipboardManager.getEnvironmentInfo();
226+
227+
console.log('剪贴板环境信息:', envInfo);
228+
console.log('尝试复制的文本:', text.substring(0, 50) + (text.length > 50 ? '...' : ''));
229+
230+
// 特殊情况:Chrome HTTP 环境下权限被拒绝
231+
if (envInfo.isChrome && envInfo.isHttp && envInfo.permission.denied) {
232+
console.log('检测到 Chrome HTTP 环境,剪贴板权限被拒绝');
233+
ClipboardManager.showPermissionDeniedMessage(onFallback, text);
72234
return;
73235
}
236+
237+
// 策略1: 优先尝试现代剪贴板 API(仅在安全上下文中)
238+
if (envInfo.isSecureContext && envInfo.hasClipboardAPI && envInfo.permission.granted) {
239+
console.log('尝试使用现代剪贴板 API...');
240+
const modernSuccess = await ClipboardManager.copyWithClipboardAPI(text);
241+
if (modernSuccess) {
242+
console.log('现代剪贴板 API 复制成功');
243+
addToast({
244+
title: '已复制',
245+
description: successMessage,
246+
color: 'success'
247+
});
248+
return;
249+
}
250+
console.log('现代剪贴板 API 复制失败');
251+
}
74252

75-
// 回退到传统方法
76-
const legacySuccess = ClipboardManager.copyWithLegacyAPI(text);
77-
if (legacySuccess) {
78-
addToast({
79-
title: '已复制',
80-
description: successMessage,
81-
color: 'success'
82-
});
83-
return;
253+
// 策略2: 使用改进的 execCommand 方法
254+
if (envInfo.hasExecCommand) {
255+
console.log('尝试使用 execCommand (div)...');
256+
const execSuccess = ClipboardManager.copyWithExecCommand(text);
257+
if (execSuccess) {
258+
console.log('execCommand (div) 复制成功');
259+
addToast({
260+
title: '已复制',
261+
description: successMessage,
262+
color: 'success'
263+
});
264+
return;
265+
}
266+
267+
console.log('尝试使用 execCommand (textarea)...');
268+
const textareaSuccess = ClipboardManager.copyWithTextarea(text);
269+
if (textareaSuccess) {
270+
console.log('execCommand (textarea) 复制成功');
271+
addToast({
272+
title: '已复制',
273+
description: successMessage,
274+
color: 'success'
275+
});
276+
return;
277+
}
84278
}
85279

86-
// 两种方法都失败
87-
if (onFallback) {
280+
// 所有方法都失败,显示相应的错误信息
281+
console.warn('所有剪贴板复制方法都失败了');
282+
283+
if (envInfo.isChrome && envInfo.isHttp) {
284+
// Chrome HTTP 环境特殊提示
285+
ClipboardManager.showPermissionDeniedMessage(onFallback, text);
286+
} else if (onFallback) {
88287
onFallback(text);
89288
} else {
90-
// 默认错误提示
289+
// 通用错误提示
91290
addToast({
92291
title: '复制失败',
93292
description: '请手动选择并复制内容',
94293
color: 'danger'
95294
});
96295
}
97296
}
297+
298+
/**
299+
* 专门用于按钮点击的复制方法
300+
* 确保在用户交互上下文中执行
301+
*/
302+
public static async copyOnClick(
303+
text: string,
304+
successMessage: string = '已复制到剪贴板',
305+
onFallback?: (text: string) => void
306+
): Promise<void> {
307+
// 在用户点击的上下文中,直接执行复制
308+
return ClipboardManager.copy(text, successMessage, onFallback);
309+
}
310+
311+
/**
312+
* 检查剪贴板功能是否可用
313+
*/
314+
public static async isSupported(): Promise<boolean> {
315+
const envInfo = await ClipboardManager.getEnvironmentInfo();
316+
return envInfo.hasClipboardAPI || envInfo.hasExecCommand || envInfo.clipboardJSSupported;
317+
}
318+
319+
/**
320+
* 获取剪贴板权限状态的友好描述
321+
*/
322+
public static async getPermissionStatus(): Promise<string> {
323+
const envInfo = await ClipboardManager.getEnvironmentInfo();
324+
325+
if (envInfo.isChrome && envInfo.isHttp) {
326+
return 'Chrome 在 HTTP 环境下限制剪贴板访问,建议使用 HTTPS';
327+
}
328+
329+
if (envInfo.permission.granted) {
330+
return '剪贴板权限已授予';
331+
} else if (envInfo.permission.denied) {
332+
return '剪贴板权限被拒绝';
333+
} else if (envInfo.permission.prompt) {
334+
return '需要请求剪贴板权限';
335+
} else {
336+
return '无法获取剪贴板权限状态';
337+
}
338+
}
98339
}
99340

100341
/**
101342
* 简化的复制函数,用于快速调用
102343
*/
103-
export const copyToClipboard = ClipboardManager.copy;
344+
export const copyToClipboard = ClipboardManager.copyOnClick;

0 commit comments

Comments
 (0)