Merge pull request #2144 from danielaskdd/fix-clipboard
Fix: Robust clipboard functionality with fallback strategies
This commit is contained in:
commit
c8b30323c0
11 changed files with 301 additions and 33 deletions
12
lightrag/api/webui/assets/feature-retrieval-B0uK1e0s.js
generated
Normal file
12
lightrag/api/webui/assets/feature-retrieval-B0uK1e0s.js
generated
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
lightrag/api/webui/index.html
generated
4
lightrag/api/webui/index.html
generated
|
|
@ -8,7 +8,7 @@
|
|||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lightrag</title>
|
||||
<script type="module" crossorigin src="/webui/assets/index-DJQ3vRUp.js"></script>
|
||||
<script type="module" crossorigin src="/webui/assets/index-eDMEw-In.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/webui/assets/react-vendor-DEwriMA6.js">
|
||||
<link rel="modulepreload" crossorigin href="/webui/assets/ui-vendor-CeCm8EER.js">
|
||||
<link rel="modulepreload" crossorigin href="/webui/assets/graph-vendor-B-X5JegA.js">
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
<link rel="modulepreload" crossorigin href="/webui/assets/feature-documents-BOF9chpi.js">
|
||||
<link rel="modulepreload" crossorigin href="/webui/assets/mermaid-vendor-DEhmckNu.js">
|
||||
<link rel="modulepreload" crossorigin href="/webui/assets/markdown-vendor-a_AXlAX2.js">
|
||||
<link rel="modulepreload" crossorigin href="/webui/assets/feature-retrieval-BUMO0erV.js">
|
||||
<link rel="modulepreload" crossorigin href="/webui/assets/feature-retrieval-B0uK1e0s.js">
|
||||
<link rel="stylesheet" crossorigin href="/webui/assets/feature-graph-BipNuM18.css">
|
||||
<link rel="stylesheet" crossorigin href="/webui/assets/index-BDkGEMA8.css">
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -351,6 +351,12 @@
|
|||
"chatMessage": {
|
||||
"copyTooltip": "نسخ إلى الحافظة",
|
||||
"copyError": "فشل نسخ النص إلى الحافظة",
|
||||
"copyEmpty": "لا يوجد محتوى للنسخ",
|
||||
"copySuccess": "تم نسخ المحتوى إلى الحافظة",
|
||||
"copySuccessLegacy": "تم نسخ المحتوى (الطريقة التقليدية)",
|
||||
"copySuccessManual": "تم نسخ المحتوى (الطريقة اليدوية)",
|
||||
"copyFailed": "فشل نسخ المحتوى",
|
||||
"copyManualInstruction": "يرجى تحديد ونسخ النص يدوياً",
|
||||
"thinking": "جاري التفكير...",
|
||||
"thinkingTime": "وقت التفكير {{time}} ثانية",
|
||||
"thinkingInProgress": "التفكير قيد التقدم..."
|
||||
|
|
|
|||
|
|
@ -351,6 +351,12 @@
|
|||
"chatMessage": {
|
||||
"copyTooltip": "Copy to clipboard",
|
||||
"copyError": "Failed to copy text to clipboard",
|
||||
"copyEmpty": "No content to copy",
|
||||
"copySuccess": "Content copied to clipboard",
|
||||
"copySuccessLegacy": "Content copied (legacy method)",
|
||||
"copySuccessManual": "Content copied (manual method)",
|
||||
"copyFailed": "Failed to copy content",
|
||||
"copyManualInstruction": "Please select and copy the text manually",
|
||||
"thinking": "Thinking...",
|
||||
"thinkingTime": "Thinking time {{time}}s",
|
||||
"thinkingInProgress": "Thinking in progress..."
|
||||
|
|
|
|||
|
|
@ -351,6 +351,12 @@
|
|||
"chatMessage": {
|
||||
"copyTooltip": "Copier dans le presse-papiers",
|
||||
"copyError": "Échec de la copie du texte dans le presse-papiers",
|
||||
"copyEmpty": "Aucun contenu à copier",
|
||||
"copySuccess": "Contenu copié dans le presse-papiers",
|
||||
"copySuccessLegacy": "Contenu copié (méthode héritée)",
|
||||
"copySuccessManual": "Contenu copié (méthode manuelle)",
|
||||
"copyFailed": "Échec de la copie du contenu",
|
||||
"copyManualInstruction": "Veuillez sélectionner et copier le texte manuellement",
|
||||
"thinking": "Réflexion en cours...",
|
||||
"thinkingTime": "Temps de réflexion {{time}}s",
|
||||
"thinkingInProgress": "Réflexion en cours..."
|
||||
|
|
|
|||
|
|
@ -351,6 +351,12 @@
|
|||
"chatMessage": {
|
||||
"copyTooltip": "复制到剪贴板",
|
||||
"copyError": "复制文本到剪贴板失败",
|
||||
"copyEmpty": "没有内容可复制",
|
||||
"copySuccess": "内容已复制到剪贴板",
|
||||
"copySuccessLegacy": "内容已复制(传统方法)",
|
||||
"copySuccessManual": "内容已复制(手动方法)",
|
||||
"copyFailed": "复制内容失败",
|
||||
"copyManualInstruction": "请手动选择并复制文本",
|
||||
"thinking": "正在思考...",
|
||||
"thinkingTime": "思考用时 {{time}} 秒",
|
||||
"thinkingInProgress": "思考进行中..."
|
||||
|
|
|
|||
|
|
@ -351,6 +351,12 @@
|
|||
"chatMessage": {
|
||||
"copyTooltip": "複製到剪貼簿",
|
||||
"copyError": "複製文字到剪貼簿失敗",
|
||||
"copyEmpty": "沒有內容可複製",
|
||||
"copySuccess": "內容已複製到剪貼簿",
|
||||
"copySuccessLegacy": "內容已複製(傳統方法)",
|
||||
"copySuccessManual": "內容已複製(手動方法)",
|
||||
"copyFailed": "複製內容失敗",
|
||||
"copyManualInstruction": "請手動選取並複製文字",
|
||||
"thinking": "正在思考...",
|
||||
"thinkingTime": "思考用時 {{time}} 秒",
|
||||
"thinkingInProgress": "思考進行中..."
|
||||
|
|
|
|||
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