Implement robust clipboard functionality with multiple fallback strategies

• Add comprehensive clipboard utility
• Support legacy browser environments
• Provide detailed user feedback
• Handle security context failures
This commit is contained in:
yangdx 2025-09-24 23:12:16 +08:00
parent 2208151b82
commit c358f83d2d
2 changed files with 244 additions and 6 deletions

View file

@ -11,6 +11,8 @@ import QuerySettings from '@/components/retrieval/QuerySettings'
import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage'
import { EraserIcon, SendIcon, CopyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/utils/clipboard'
import type { QueryMode } from '@/api/lightrag'
// Helper function to generate unique IDs with browser compatibility
@ -587,7 +589,7 @@ export default function RetrievalTesting() {
useSettingsStore.getState().setRetrievalHistory([])
}, [setMessages])
// Handle copying message content
// Handle copying message content with robust clipboard support
const handleCopyMessage = useCallback(async (message: MessageWithError) => {
let contentToCopy = '';
@ -602,12 +604,50 @@ export default function RetrievalTesting() {
contentToCopy = finalDisplayContent;
}
if (contentToCopy.trim()) {
try {
await navigator.clipboard.writeText(contentToCopy)
} catch (err) {
console.error(t('chat.copyError'), err)
if (!contentToCopy.trim()) {
toast.error(t('retrievePanel.chatMessage.copyEmpty', 'No content to copy'));
return;
}
try {
const result = await copyToClipboard(contentToCopy);
if (result.success) {
// Show success message with method used
const methodMessages: Record<string, string> = {
'clipboard-api': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'),
'execCommand': t('retrievePanel.chatMessage.copySuccessLegacy', 'Content copied (legacy method)'),
'manual-select': t('retrievePanel.chatMessage.copySuccessManual', 'Content copied (manual method)'),
'fallback': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard')
};
toast.success(methodMessages[result.method] || t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'));
} else {
// Show error with fallback instructions
if (result.method === 'fallback') {
toast.error(
result.error || t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
{
description: t('retrievePanel.chatMessage.copyManualInstruction', 'Please select and copy the text manually')
}
);
} else {
toast.error(
t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
{
description: result.error
}
);
}
}
} catch (err) {
console.error('Clipboard operation failed:', err);
toast.error(
t('retrievePanel.chatMessage.copyError', 'Copy operation failed'),
{
description: err instanceof Error ? err.message : 'Unknown error occurred'
}
);
}
}, [t])

View file

@ -0,0 +1,198 @@
/**
* Robust clipboard utility with multiple fallback strategies
* Handles various browser environments and security contexts
*/
export interface CopyResult {
success: boolean;
method: 'clipboard-api' | 'execCommand' | 'manual-select' | 'fallback';
error?: string;
}
/**
* Copy text to clipboard with multiple fallback strategies
* @param text - Text to copy to clipboard
* @returns Promise<CopyResult> - Result object with success status and method used
*/
export async function copyToClipboard(text: string): Promise<CopyResult> {
if (!text || text.trim() === '') {
return {
success: false,
method: 'fallback',
error: 'No text provided'
};
}
// Strategy 1: Modern Clipboard API (preferred)
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return {
success: true,
method: 'clipboard-api'
};
} catch (error) {
console.warn('Clipboard API failed:', error);
// Continue to fallback methods
}
}
// Strategy 2: Legacy execCommand (for older browsers)
try {
const result = await copyWithExecCommand(text);
if (result.success) {
return result;
}
} catch (error) {
console.warn('execCommand failed:', error);
// Continue to fallback methods
}
// Strategy 3: Manual text selection (most compatible)
try {
const result = await copyWithManualSelection(text);
if (result.success) {
return result;
}
} catch (error) {
console.warn('Manual selection failed:', error);
}
// Strategy 4: Complete fallback - return error
return {
success: false,
method: 'fallback',
error: 'All copy methods failed. Please copy the text manually.'
};
}
/**
* Copy using legacy execCommand method
*/
async function copyWithExecCommand(text: string): Promise<CopyResult> {
return new Promise((resolve) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
textarea.setAttribute('readonly', '');
document.body.appendChild(textarea);
try {
textarea.select();
textarea.setSelectionRange(0, text.length);
const successful = document.execCommand('copy');
if (successful) {
resolve({
success: true,
method: 'execCommand'
});
} else {
resolve({
success: false,
method: 'execCommand',
error: 'execCommand returned false'
});
}
} catch (error) {
resolve({
success: false,
method: 'execCommand',
error: error instanceof Error ? error.message : 'execCommand failed'
});
} finally {
document.body.removeChild(textarea);
}
});
}
/**
* Copy using manual text selection method
*/
async function copyWithManualSelection(text: string): Promise<CopyResult> {
return new Promise((resolve) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
textarea.setAttribute('readonly', '');
textarea.setAttribute('tabindex', '-1');
document.body.appendChild(textarea);
try {
// Focus and select the text
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, text.length);
// Try to trigger copy event
const copyEvent = new ClipboardEvent('copy', {
clipboardData: new DataTransfer()
});
if (copyEvent.clipboardData) {
copyEvent.clipboardData.setData('text/plain', text);
document.dispatchEvent(copyEvent);
resolve({
success: true,
method: 'manual-select'
});
} else {
// Fallback: keep text selected for manual copy
resolve({
success: false,
method: 'manual-select',
error: 'Manual selection prepared, but automatic copy failed'
});
}
} catch (error) {
resolve({
success: false,
method: 'manual-select',
error: error instanceof Error ? error.message : 'Manual selection failed'
});
} finally {
// Clean up after a short delay to allow copy operation
setTimeout(() => {
if (document.body.contains(textarea)) {
document.body.removeChild(textarea);
}
}, 100);
}
});
}
/**
* Check if clipboard functionality is available
*/
export function isClipboardSupported(): boolean {
return !!(
(navigator.clipboard && typeof navigator.clipboard.writeText === 'function') ||
typeof document !== 'undefined'
);
}
/**
* Get the best available clipboard method
*/
export function getBestClipboardMethod(): 'clipboard-api' | 'execCommand' | 'manual-select' | 'none' {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
return 'clipboard-api';
}
if (typeof document !== 'undefined') {
return 'execCommand';
}
return 'none';
}