Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions data-agent-frontend-nuxt/app/utils/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
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);
});
});
71 changes: 71 additions & 0 deletions data-agent-frontend-nuxt/app/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
// 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -92,24 +93,23 @@ 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?.('复制成功');
setTimeout(() => {
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);
});
}
});
};
}

Expand Down
Loading