diff --git a/data-agent-frontend-nuxt/app/utils/clipboard.test.ts b/data-agent-frontend-nuxt/app/utils/clipboard.test.ts new file mode 100644 index 000000000..36c54fde2 --- /dev/null +++ b/data-agent-frontend-nuxt/app/utils/clipboard.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { copyTextToClipboard } from './clipboard'; + +/** + * Minimal fake DOM so the execCommand fallback can be exercised in the default + * node test environment (no jsdom dependency, matching the rest of the suite). + */ +function stubFakeDocument(execResult: boolean) { + const appended: unknown[] = []; + const execCommand = vi.fn().mockReturnValue(execResult); + const fakeDocument = { + createElement: vi.fn(() => ({ + style: {} as Record, + setAttribute: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: '', + })), + body: { + appendChild: vi.fn((el: unknown) => appended.push(el)), + removeChild: vi.fn((el: unknown) => { + const i = appended.indexOf(el); + if (i >= 0) appended.splice(i, 1); + }), + }, + execCommand, + }; + vi.stubGlobal('document', fakeDocument); + return { appended, execCommand }; +} + +describe('copyTextToClipboard', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('uses navigator.clipboard in a secure context', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('window', { isSecureContext: true }); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + const result = await copyTextToClipboard('SELECT 1'); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith('SELECT 1'); + }); + + it('falls back to execCommand when writeText rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')); + vi.stubGlobal('window', { isSecureContext: true }); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const { execCommand } = stubFakeDocument(true); + + const result = await copyTextToClipboard('SELECT 1'); + + expect(result).toBe(true); + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + + it('falls back to execCommand over plain HTTP on a LAN IP', async () => { + // navigator.clipboard is undefined in a non-secure context. + vi.stubGlobal('window', { isSecureContext: false }); + vi.stubGlobal('navigator', {}); + const { execCommand } = stubFakeDocument(true); + + const result = await copyTextToClipboard('hello world'); + + expect(result).toBe(true); + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + + it('returns false when execCommand reports failure', async () => { + vi.stubGlobal('window', { isSecureContext: false }); + vi.stubGlobal('navigator', {}); + stubFakeDocument(false); + + const result = await copyTextToClipboard('hello world'); + + expect(result).toBe(false); + }); + + it('removes the temporary textarea after copying', async () => { + vi.stubGlobal('window', { isSecureContext: false }); + vi.stubGlobal('navigator', {}); + const { appended } = stubFakeDocument(true); + + await copyTextToClipboard('cleanup check'); + + expect(appended).toHaveLength(0); + }); +}); diff --git a/data-agent-frontend-nuxt/app/utils/clipboard.ts b/data-agent-frontend-nuxt/app/utils/clipboard.ts new file mode 100644 index 000000000..5316cf92d --- /dev/null +++ b/data-agent-frontend-nuxt/app/utils/clipboard.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Copy text to the clipboard with a fallback for non-secure contexts. + * + * The async Clipboard API (`navigator.clipboard`) is only available in secure + * contexts (HTTPS or localhost). When the app is served over plain HTTP on a + * LAN IP, `navigator.clipboard` is `undefined`, so we fall back to the legacy + * `document.execCommand('copy')` using a temporary textarea. + * + * @returns `true` if the copy succeeded, `false` otherwise. + */ +export async function copyTextToClipboard(text: string): Promise { + // Preferred path: async Clipboard API (secure contexts only). + if ( + typeof navigator !== 'undefined' && + navigator.clipboard && + typeof navigator.clipboard.writeText === 'function' && + (typeof window === 'undefined' || window.isSecureContext !== false) + ) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to the legacy fallback below. + } + } + + return copyWithExecCommand(text); +} + +/** + * Legacy clipboard copy using a hidden textarea and `document.execCommand`. + * Works in non-secure contexts where `navigator.clipboard` is unavailable. + */ +function copyWithExecCommand(text: string): boolean { + if (typeof document === 'undefined') return false; + + const textarea = document.createElement('textarea'); + textarea.value = text; + // Keep it out of view and prevent scrolling/zooming side effects. + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.left = '-9999px'; + textarea.setAttribute('readonly', ''); + + document.body.appendChild(textarea); + try { + textarea.select(); + textarea.setSelectionRange(0, text.length); + return document.execCommand('copy'); + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } +} diff --git a/data-agent-frontend-nuxt/app/utils/markdown/markdown-plugin-highlight.ts b/data-agent-frontend-nuxt/app/utils/markdown/markdown-plugin-highlight.ts index b4800a5f9..05ef4acee 100644 --- a/data-agent-frontend-nuxt/app/utils/markdown/markdown-plugin-highlight.ts +++ b/data-agent-frontend-nuxt/app/utils/markdown/markdown-plugin-highlight.ts @@ -21,6 +21,7 @@ import Python from 'highlight.js/lib/languages/python'; import Json from 'highlight.js/lib/languages/json'; import JavaScript from 'highlight.js/lib/languages/javascript'; import type MarkdownIt from 'markdown-it'; +import { copyTextToClipboard } from '../clipboard'; hljs.registerLanguage('sql', Sql); hljs.registerLanguage('json', Json); @@ -92,9 +93,8 @@ if (typeof window !== 'undefined' && !window.copyCodeBlock) { if (!decodedCode) return; - navigator.clipboard - .writeText(decodedCode) - .then(() => { + copyTextToClipboard(decodedCode).then((success) => { + if (success) { btn.textContent = '已复制!'; btn.classList.add('copied'); window.__tipShow?.('复制成功'); @@ -102,14 +102,14 @@ if (typeof window !== 'undefined' && !window.copyCodeBlock) { btn.textContent = originalText; btn.classList.remove('copied'); }, 2000); - }) - .catch(() => { + } else { btn.textContent = '复制失败'; window.__tipShow?.('复制失败', { color: 'error', icon: 'mdi-alert-circle' }); setTimeout(() => { btn.textContent = originalText; }, 2000); - }); + } + }); }; }