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:
parent
2208151b82
commit
c358f83d2d
2 changed files with 244 additions and 6 deletions
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
198
lightrag_webui/src/utils/clipboard.ts
Normal file
198
lightrag_webui/src/utils/clipboard.ts
Normal 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';
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue