11import { addToast } from "@heroui/toast" ;
2+ import ClipboardJS from 'clipboard' ;
23
34/**
45 * 健壮的剪贴板复制工具
5- * 支持现代 Clipboard API、传统 execCommand 和手动复制备选方案
6+ * 包含权限检测和用户友好的提示
67 */
78export 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 / C h r o m e / . test ( navigator . userAgent ) && / G o o g l e I n c / . 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