diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue index a0e70d65fe..45cc99b70a 100644 --- a/dashboard/src/components/chat/ChatMessageList.vue +++ b/dashboard/src/components/chat/ChatMessageList.vue @@ -388,6 +388,7 @@ import type { MessagePart, } from "@/composables/useMessages"; import { useI18n, useModuleI18n } from "@/i18n/composables"; +import { copyToClipboard } from "@/utils/clipboard"; const props = withDefaults( defineProps<{ @@ -779,7 +780,7 @@ function toolCallStatusText(tool: Record) { async function copyMessage(message: ChatRecord) { const text = plainTextFromMessage(message); if (!text) return; - await navigator.clipboard?.writeText(text); + await copyToClipboard(text); } async function downloadPart(part: MessagePart) { diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index d3070f73e5..7ae1dcd2aa 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -254,6 +254,7 @@ import type { MessagePart, } from "@/composables/useMessages"; import { useModuleI18n } from "@/i18n/composables"; +import { copyToClipboard } from "@/utils/clipboard"; const props = withDefaults( defineProps<{ @@ -449,7 +450,7 @@ function parseJsonSafe(value: unknown) { async function copyMessage(message: ChatRecord) { const text = plainTextFromMessage(message); if (!text) return; - await navigator.clipboard?.writeText(text); + await copyToClipboard(text, { container: messageListRoot.value }); } async function downloadPart(part: MessagePart) { diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue index 63fb8359d5..eb159f62eb 100644 --- a/dashboard/src/components/shared/ReadmeDialog.vue +++ b/dashboard/src/components/shared/ReadmeDialog.vue @@ -5,6 +5,7 @@ import MarkdownIt from "markdown-it"; import axios from "axios"; import DOMPurify from "dompurify"; import { useI18n } from "@/i18n/composables"; +import { copyToClipboard } from "@/utils/clipboard"; import { escapeHtml, ensureShikiLanguages, @@ -349,19 +350,13 @@ watch([content, locale, isDark], () => { updateRenderedHtml(); }, { immediate: true }); -function handleContainerClick(event) { +async function handleContainerClick(event) { const btn = event.target.closest(".copy-code-btn"); if (btn) { const code = btn.closest(".code-block-wrapper")?.querySelector("code"); if (code) { - if (navigator.clipboard?.writeText) { - navigator.clipboard - .writeText(code.textContent) - .then(() => showCopyFeedback(btn, true)) - .catch(() => tryFallbackCopy(code.textContent, btn)); - } else { - tryFallbackCopy(code.textContent, btn); - } + const success = await copyToClipboard(code.textContent || ""); + showCopyFeedback(btn, success); } return; } @@ -382,25 +377,6 @@ function handleContainerClick(event) { target.scrollIntoView({ behavior: "smooth", block: "start" }); } -function tryFallbackCopy(text, btn) { - try { - const textArea = document.createElement("textarea"); - textArea.value = text; - Object.assign(textArea.style, { - position: "absolute", - opacity: "0", - zIndex: "-1", - }); - btn.parentNode.appendChild(textArea); - textArea.select(); - const success = document.execCommand("copy"); - btn.parentNode.removeChild(textArea); - showCopyFeedback(btn, success); - } catch (err) { - showCopyFeedback(btn, false); - } -} - function showCopyFeedback(btn, success) { if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value); btn.setAttribute("title", t(`core.common.${success ? "copied" : "error"}`)); diff --git a/dashboard/src/utils/clipboard.ts b/dashboard/src/utils/clipboard.ts new file mode 100644 index 0000000000..664a143fb4 --- /dev/null +++ b/dashboard/src/utils/clipboard.ts @@ -0,0 +1,92 @@ +interface CopyToClipboardOptions { + container?: HTMLElement | null; +} + +export async function copyToClipboard( + text: string, + options: CopyToClipboardOptions = {}, +): Promise { + const container = options.container; + const debugInfo = { + length: text?.length ?? 0, + trimmedLength: text?.trim().length ?? 0, + isSecureContext: typeof window !== "undefined" ? window.isSecureContext : false, + hasClipboardApi: + typeof navigator !== "undefined" && !!navigator.clipboard?.writeText, + containerTag: container?.tagName ?? null, + containerInBody: + typeof document !== "undefined" && !!container && document.body.contains(container), + }; + + if (!text) { + console.debug("[clipboard] empty text payload", debugInfo); + return false; + } + + console.debug("[clipboard] copy request", debugInfo); + + if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + console.info("[clipboard] copied via Clipboard API", debugInfo); + return true; + } catch (err) { + console.warn("[clipboard] Clipboard API failed, falling back:", err, debugInfo); + } + } + + const fallbackOk = fallbackCopy(text, container); + if (fallbackOk) { + console.info("[clipboard] fallback succeeded via document.execCommand('copy')", debugInfo); + } else { + console.warn("[clipboard] fallback failed via document.execCommand('copy')", debugInfo); + } + return fallbackOk; +} + +function fallbackCopy(text: string, container?: HTMLElement | null): boolean { + if (typeof document === "undefined" || !document.body) return false; + + const mountTarget = + container && document.body.contains(container) ? container : document.body; + const textArea = document.createElement("textarea"); + const activeElement = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const selection = document.getSelection(); + const selectedRanges = selection + ? Array.from({ length: selection.rangeCount }, (_, index) => + selection.getRangeAt(index).cloneRange(), + ) + : []; + + textArea.value = text; + textArea.readOnly = true; + Object.assign(textArea.style, { + position: "fixed", + left: "-9999px", + top: "0", + opacity: "0", + pointerEvents: "none", + }); + + mountTarget.appendChild(textArea); + textArea.focus(); + textArea.select(); + textArea.setSelectionRange(0, text.length); + + try { + return document.execCommand("copy"); + } catch (err) { + console.error("Fallback copy failed:", err); + return false; + } finally { + if (textArea.parentNode) { + textArea.parentNode.removeChild(textArea); + } + if (selection) { + selection.removeAllRanges(); + selectedRanges.forEach((range) => selection.addRange(range)); + } + activeElement?.focus?.(); + } +} diff --git a/dashboard/src/views/ConversationPage.vue b/dashboard/src/views/ConversationPage.vue index 95a7c90e8a..f7dceca643 100644 --- a/dashboard/src/views/ConversationPage.vue +++ b/dashboard/src/views/ConversationPage.vue @@ -379,6 +379,7 @@ import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'; +import { copyToClipboard } from '@/utils/clipboard'; export default { name: 'ConversationPage', @@ -638,10 +639,10 @@ export default { }, async copyUmoSource(item) { - try { - await navigator.clipboard.writeText(this.formatUmoSource(item)); + const ok = await copyToClipboard(this.formatUmoSource(item)); + if (ok) { this.showSuccessMessage(this.tm('messages.copySuccess')); - } catch (error) { + } else { this.showErrorMessage(this.tm('messages.copyError')); } }, diff --git a/dashboard/src/views/PlatformPage.vue b/dashboard/src/views/PlatformPage.vue index 678274f6de..87f5d2dc27 100644 --- a/dashboard/src/views/PlatformPage.vue +++ b/dashboard/src/views/PlatformPage.vue @@ -241,6 +241,7 @@ import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'; +import { copyToClipboard } from '@/utils/clipboard'; export default { name: 'PlatformPage', @@ -608,10 +609,10 @@ export default { async copyWebhookUrl(webhookUuid) { const url = this.getWebhookUrl(webhookUuid); - try { - await navigator.clipboard.writeText(url); + const ok = await copyToClipboard(url); + if (ok) { this.showSuccess(this.tm('webhookCopied')); - } catch (err) { + } else { this.showError(this.tm('webhookCopyFailed')); } } diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 9ddb4e1fec..a870841c70 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -236,6 +236,7 @@ import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue'; import BackupDialog from '@/components/shared/BackupDialog.vue'; import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue'; import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'; +import { copyToClipboard } from '@/utils/clipboard'; import { useModuleI18n } from '@/i18n/composables'; import { useTheme } from 'vuetify'; import { PurpleTheme } from '@/theme/LightTheme'; @@ -338,50 +339,9 @@ const loadApiKeys = async () => { } }; -const tryExecCommandCopy = (text) => { - let textArea = null; - try { - if (typeof document === 'undefined' || !document.body) return false; - textArea = document.createElement('textarea'); - textArea.value = text; - textArea.setAttribute('readonly', ''); - textArea.style.position = 'fixed'; - textArea.style.opacity = '0'; - textArea.style.pointerEvents = 'none'; - textArea.style.left = '-9999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - textArea.setSelectionRange(0, text.length); - return document.execCommand('copy'); - } catch (_) { - return false; - } finally { - try { - if (textArea?.parentNode) { - textArea.parentNode.removeChild(textArea); - } - } catch (_) { - // ignore cleanup errors - } - } -}; - -const copyTextToClipboard = async (text) => { - if (!text) return false; - if (tryExecCommandCopy(text)) return true; - if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false; - try { - await navigator.clipboard.writeText(text); - return true; - } catch (_) { - return false; - } -}; - const copyCreatedApiKey = async () => { if (!createdApiKeyPlaintext.value) return; - const ok = await copyTextToClipboard(createdApiKeyPlaintext.value); + const ok = await copyToClipboard(createdApiKeyPlaintext.value); if (ok) { showToast(tm('apiKey.messages.copySuccess'), 'success'); } else {