From fd8a2dc4e1738565e1f4ef7fd2946541df3de9a4 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 16 May 2026 03:41:59 +0800 Subject: [PATCH 1/3] fix: guard clipboard copy actions --- .changeset/clipboard-copy-fallback.md | 6 ++ .../app/src/components/DBRowJsonViewer.tsx | 37 +++++++++++-- .../DBTable/DBRowTableFieldWithPopover.tsx | 13 ++++- .../DBTable/DBRowTableRowButtons.tsx | 22 +++++++- packages/app/src/utils/clipboard.test.ts | 51 +++++++++++++++++ packages/app/src/utils/clipboard.ts | 55 +++++++++++++++++++ 6 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 .changeset/clipboard-copy-fallback.md create mode 100644 packages/app/src/utils/clipboard.test.ts create mode 100644 packages/app/src/utils/clipboard.ts diff --git a/.changeset/clipboard-copy-fallback.md b/.changeset/clipboard-copy-fallback.md new file mode 100644 index 0000000000..317dbac796 --- /dev/null +++ b/.changeset/clipboard-copy-fallback.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +--- + +Fall back when the browser Clipboard API is unavailable and show a clear error +if copying still fails. diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index f1eac9fa43..9f345849f4 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -29,6 +29,7 @@ import { import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson'; import { mergePath } from '@/utils'; +import { copyTextToClipboard } from '@/utils/clipboard'; type JSONExtractFn = | 'JSONExtractString' @@ -219,12 +220,20 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { {rowData != null && ( { - window.navigator.clipboard.writeText( + onClick={async () => { + const copied = await copyTextToClipboard( typeof rowData === 'string' ? rowData : JSON.stringify(rowData, null, 2), ); + if (!copied) { + notifications.show({ + color: 'red', + message: + 'Could not access the clipboard. Check browser permissions or use HTTPS.', + }); + return; + } notifications.show({ color: 'green', message: `Value copied to clipboard`, @@ -547,7 +556,7 @@ export function DBRowJsonViewer({ }); } - const handleCopyObject = () => { + const handleCopyObject = async () => { let copiedObj; // When in parsed JSON context (e.g., expanded stringified JSON), @@ -559,9 +568,17 @@ export function DBRowJsonViewer({ copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath); } - window.navigator.clipboard.writeText( + const copied = await copyTextToClipboard( JSON.stringify(copiedObj, null, 2), ); + if (!copied) { + notifications.show({ + color: 'red', + message: + 'Could not access the clipboard. Check browser permissions or use HTTPS.', + }); + return; + } notifications.show({ color: 'green', message: `Copied object to clipboard`, @@ -583,12 +600,20 @@ export function DBRowJsonViewer({ Copy Value ), - onClick: () => { - window.navigator.clipboard.writeText( + onClick: async () => { + const copied = await copyTextToClipboard( typeof value === 'string' ? value : JSON.stringify(value, null, 2), ); + if (!copied) { + notifications.show({ + color: 'red', + message: + 'Could not access the clipboard. Check browser permissions or use HTTPS.', + }); + return; + } notifications.show({ color: 'green', message: `Value copied to clipboard`, diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx index 0f410db20e..a07143e02a 100644 --- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx +++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx @@ -2,8 +2,11 @@ import React, { useContext, useEffect, useRef, useState } from 'react'; import cx from 'classnames'; import { Popover } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react'; +import { copyTextToClipboard } from '@/utils/clipboard'; + import { RowSidePanelContext } from '../DBRowSidePanel'; import { DBRowTableIconButton } from './DBRowTableIconButton'; @@ -86,7 +89,15 @@ const DBRowTableFieldWithPopover = ({ try { const value = typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); - await navigator.clipboard.writeText(value); + const copied = await copyTextToClipboard(value); + if (!copied) { + notifications.show({ + color: 'red', + message: + 'Could not access the clipboard. Check browser permissions or use HTTPS.', + }); + return; + } setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); } catch (error) { diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx index 6e0fc23fd2..acea56d502 100644 --- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -1,7 +1,9 @@ import React, { useState } from 'react'; +import { notifications } from '@mantine/notifications'; import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react'; import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere'; +import { copyTextToClipboard } from '@/utils/clipboard'; import { DBRowTableIconButton } from './DBRowTableIconButton'; @@ -53,7 +55,15 @@ const DBRowTableRowButtons: React.FC = ({ ); const rowData = JSON.stringify(parsedRow, null, 2); - await navigator.clipboard.writeText(rowData); + const copied = await copyTextToClipboard(rowData); + if (!copied) { + notifications.show({ + color: 'red', + message: + 'Could not access the clipboard. Check browser permissions or use HTTPS.', + }); + return; + } setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); } catch (error) { @@ -71,7 +81,15 @@ const DBRowTableRowButtons: React.FC = ({ if (sourceId) { currentUrl.searchParams.set('rowSource', sourceId); } - await navigator.clipboard.writeText(currentUrl.toString()); + const copied = await copyTextToClipboard(currentUrl.toString()); + if (!copied) { + notifications.show({ + color: 'red', + message: + 'Could not access the clipboard. Check browser permissions or use HTTPS.', + }); + return; + } setIsUrlCopied(true); setTimeout(() => setIsUrlCopied(false), 2000); } catch (error) { diff --git a/packages/app/src/utils/clipboard.test.ts b/packages/app/src/utils/clipboard.test.ts new file mode 100644 index 0000000000..95c0fbd927 --- /dev/null +++ b/packages/app/src/utils/clipboard.test.ts @@ -0,0 +1,51 @@ +import { copyTextToClipboard } from './clipboard'; + +describe('copyTextToClipboard', () => { + const originalClipboard = navigator.clipboard; + const originalExecCommand = document.execCommand; + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); + document.execCommand = originalExecCommand; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); + + it('uses the Clipboard API when it is available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await expect(copyTextToClipboard('hello')).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith('hello'); + }); + + it('falls back to a textarea copy when the Clipboard API is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(true); + + await expect(copyTextToClipboard('fallback text')).resolves.toBe(true); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(document.querySelector('textarea')).toBeNull(); + }); + + it('reports failure when both copy methods are unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(false); + + await expect(copyTextToClipboard('nope')).resolves.toBe(false); + }); +}); diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts new file mode 100644 index 0000000000..2b77d679ca --- /dev/null +++ b/packages/app/src/utils/clipboard.ts @@ -0,0 +1,55 @@ +export async function copyTextToClipboard(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Fall through to the legacy copy path below. + } + + return copyTextWithTextarea(text); +} + +function copyTextWithTextarea(text: string): boolean { + if (typeof document === 'undefined' || !document.body) { + return false; + } + + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.setAttribute('readonly', ''); + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.width = '1px'; + textArea.style.height = '1px'; + textArea.style.padding = '0'; + textArea.style.border = 'none'; + textArea.style.outline = 'none'; + textArea.style.boxShadow = 'none'; + textArea.style.background = 'transparent'; + + const selection = document.getSelection(); + const previousRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + document.body.appendChild(textArea); + textArea.select(); + textArea.setSelectionRange(0, textArea.value.length); + + let copied = false; + try { + copied = document.execCommand('copy'); + } catch { + copied = false; + } finally { + document.body.removeChild(textArea); + if (previousRange && selection) { + selection.removeAllRanges(); + selection.addRange(previousRange); + } + } + + return copied; +} From 87f079bc599d0081d87275d788f214bef693cd9c Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 16 May 2026 18:23:53 +0800 Subject: [PATCH 2/3] fix: tighten clipboard fallback handling --- .../app/src/components/DBRowJsonViewer.tsx | 14 +-- .../DBTable/DBRowTableFieldWithPopover.tsx | 33 +++--- .../DBTable/DBRowTableRowButtons.tsx | 109 ++++++++---------- packages/app/src/utils/clipboard.test.ts | 28 +++++ packages/app/src/utils/clipboard.ts | 19 ++- 5 files changed, 113 insertions(+), 90 deletions(-) diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index 9f345849f4..65abd56115 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -29,7 +29,10 @@ import { import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson'; import { mergePath } from '@/utils'; -import { copyTextToClipboard } from '@/utils/clipboard'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; type JSONExtractFn = | 'JSONExtractString' @@ -229,8 +232,7 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { if (!copied) { notifications.show({ color: 'red', - message: - 'Could not access the clipboard. Check browser permissions or use HTTPS.', + message: CLIPBOARD_ERROR_MESSAGE, }); return; } @@ -574,8 +576,7 @@ export function DBRowJsonViewer({ if (!copied) { notifications.show({ color: 'red', - message: - 'Could not access the clipboard. Check browser permissions or use HTTPS.', + message: CLIPBOARD_ERROR_MESSAGE, }); return; } @@ -609,8 +610,7 @@ export function DBRowJsonViewer({ if (!copied) { notifications.show({ color: 'red', - message: - 'Could not access the clipboard. Check browser permissions or use HTTPS.', + message: CLIPBOARD_ERROR_MESSAGE, }); return; } diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx index a07143e02a..a36fc15f37 100644 --- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx +++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx @@ -5,7 +5,10 @@ import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react'; -import { copyTextToClipboard } from '@/utils/clipboard'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; import { RowSidePanelContext } from '../DBRowSidePanel'; @@ -86,24 +89,18 @@ const DBRowTableFieldWithPopover = ({ }; const copyFieldValue = async () => { - try { - const value = - typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); - const copied = await copyTextToClipboard(value); - if (!copied) { - notifications.show({ - color: 'red', - message: - 'Could not access the clipboard. Check browser permissions or use HTTPS.', - }); - return; - } - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (error) { - console.error('Failed to copy to clipboard:', error); - // Optionally show an error toast notification to the user + const value = + typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); + const copied = await copyTextToClipboard(value); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; } + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); }; const addFilter = () => { diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx index acea56d502..9f10912488 100644 --- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -3,7 +3,10 @@ import { notifications } from '@mantine/notifications'; import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react'; import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere'; -import { copyTextToClipboard } from '@/utils/clipboard'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; import { DBRowTableIconButton } from './DBRowTableIconButton'; @@ -28,74 +31,62 @@ const DBRowTableRowButtons: React.FC = ({ const [isUrlCopied, setIsUrlCopied] = useState(false); const copyRowData = async () => { - try { - // Filter out internal metadata fields that start with __ or are generated IDs + // Filter out internal metadata fields that start with __ or are generated IDs - const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row; + const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row; - // Parse JSON string fields to make them proper JSON objects - const parsedRow = Object.entries(cleanRow).reduce( - (acc, [key, value]) => { - if ( - typeof value === 'string' && - (value.startsWith('{') || value.startsWith('[')) - ) { - try { - acc[key] = JSON.parse(value); - } catch { - // If parsing fails, keep the original string - acc[key] = value; - } - } else { + // Parse JSON string fields to make them proper JSON objects + const parsedRow = Object.entries(cleanRow).reduce( + (acc, [key, value]) => { + if ( + typeof value === 'string' && + (value.startsWith('{') || value.startsWith('[')) + ) { + try { + acc[key] = JSON.parse(value); + } catch { + // If parsing fails, keep the original string acc[key] = value; } - return acc; - }, - {} as Record, - ); + } else { + acc[key] = value; + } + return acc; + }, + {} as Record, + ); - const rowData = JSON.stringify(parsedRow, null, 2); - const copied = await copyTextToClipboard(rowData); - if (!copied) { - notifications.show({ - color: 'red', - message: - 'Could not access the clipboard. Check browser permissions or use HTTPS.', - }); - return; - } - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (error) { - console.error('Failed to copy row data to clipboard:', error); - // Optionally show an error toast notification to the user + const rowData = JSON.stringify(parsedRow, null, 2); + const copied = await copyTextToClipboard(rowData); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; } + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); }; const copyRowUrl = async () => { - try { - const rowWhereResult = getRowWhere(row); - const currentUrl = new URL(window.location.href); - // Add the row identifier as query parameters - currentUrl.searchParams.set('rowWhere', rowWhereResult.where); - if (sourceId) { - currentUrl.searchParams.set('rowSource', sourceId); - } - const copied = await copyTextToClipboard(currentUrl.toString()); - if (!copied) { - notifications.show({ - color: 'red', - message: - 'Could not access the clipboard. Check browser permissions or use HTTPS.', - }); - return; - } - setIsUrlCopied(true); - setTimeout(() => setIsUrlCopied(false), 2000); - } catch (error) { - console.error('Failed to copy URL to clipboard:', error); - // Optionally show an error toast notification to the user + const rowWhereResult = getRowWhere(row); + const currentUrl = new URL(window.location.href); + // Add the row identifier as query parameters + currentUrl.searchParams.set('rowWhere', rowWhereResult.where); + if (sourceId) { + currentUrl.searchParams.set('rowSource', sourceId); + } + const copied = await copyTextToClipboard(currentUrl.toString()); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; } + setIsUrlCopied(true); + setTimeout(() => setIsUrlCopied(false), 2000); }; return ( diff --git a/packages/app/src/utils/clipboard.test.ts b/packages/app/src/utils/clipboard.test.ts index 95c0fbd927..c50dde5211 100644 --- a/packages/app/src/utils/clipboard.test.ts +++ b/packages/app/src/utils/clipboard.test.ts @@ -39,6 +39,34 @@ describe('copyTextToClipboard', () => { expect(document.querySelector('textarea')).toBeNull(); }); + it('does not fall back when the Clipboard API rejects', async () => { + const writeText = jest.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + document.execCommand = jest.fn().mockReturnValue(true); + + await expect(copyTextToClipboard('blocked')).resolves.toBe(false); + + expect(writeText).toHaveBeenCalledWith('blocked'); + expect(document.execCommand).not.toHaveBeenCalled(); + }); + + it('removes the fallback textarea when execCommand throws', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn(() => { + throw new Error('copy failed'); + }); + + await expect(copyTextToClipboard('throwing copy')).resolves.toBe(false); + + expect(document.querySelector('textarea')).toBeNull(); + }); + it('reports failure when both copy methods are unavailable', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts index 2b77d679ca..c1c0145093 100644 --- a/packages/app/src/utils/clipboard.ts +++ b/packages/app/src/utils/clipboard.ts @@ -1,11 +1,14 @@ +export const CLIPBOARD_ERROR_MESSAGE = + 'Could not access the clipboard. Check browser permissions or use HTTPS.'; + export async function copyTextToClipboard(text: string): Promise { - try { - if (navigator.clipboard?.writeText) { + if (navigator.clipboard?.writeText) { + try { await navigator.clipboard.writeText(text); return true; + } catch { + return false; } - } catch { - // Fall through to the legacy copy path below. } return copyTextWithTextarea(text); @@ -46,8 +49,12 @@ function copyTextWithTextarea(text: string): boolean { } finally { document.body.removeChild(textArea); if (previousRange && selection) { - selection.removeAllRanges(); - selection.addRange(previousRange); + try { + selection.removeAllRanges(); + selection.addRange(previousRange); + } catch { + // Ignore restore failures if the previous selection was detached. + } } } From 345161c5c092dbd490b2c085ec316f7ee1581ff8 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 16 May 2026 18:43:36 +0800 Subject: [PATCH 3/3] fix: address clipboard fallback review --- .../DBTable/DBRowTableRowButtons.tsx | 59 ++++---- .../app/src/utils/__tests__/clipboard.test.ts | 131 ++++++++++++++++++ packages/app/src/utils/clipboard.test.ts | 79 ----------- packages/app/src/utils/clipboard.ts | 9 +- 4 files changed, 172 insertions(+), 106 deletions(-) create mode 100644 packages/app/src/utils/__tests__/clipboard.test.ts delete mode 100644 packages/app/src/utils/clipboard.test.ts diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx index 9f10912488..fb08da39e3 100644 --- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -31,42 +31,49 @@ const DBRowTableRowButtons: React.FC = ({ const [isUrlCopied, setIsUrlCopied] = useState(false); const copyRowData = async () => { - // Filter out internal metadata fields that start with __ or are generated IDs + try { + // Filter out internal metadata fields that start with __ or are generated IDs - const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row; + const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row; - // Parse JSON string fields to make them proper JSON objects - const parsedRow = Object.entries(cleanRow).reduce( - (acc, [key, value]) => { - if ( - typeof value === 'string' && - (value.startsWith('{') || value.startsWith('[')) - ) { - try { - acc[key] = JSON.parse(value); - } catch { - // If parsing fails, keep the original string + // Parse JSON string fields to make them proper JSON objects + const parsedRow = Object.entries(cleanRow).reduce( + (acc, [key, value]) => { + if ( + typeof value === 'string' && + (value.startsWith('{') || value.startsWith('[')) + ) { + try { + acc[key] = JSON.parse(value); + } catch { + // If parsing fails, keep the original string + acc[key] = value; + } + } else { acc[key] = value; } - } else { - acc[key] = value; - } - return acc; - }, - {} as Record, - ); + return acc; + }, + {} as Record, + ); - const rowData = JSON.stringify(parsedRow, null, 2); - const copied = await copyTextToClipboard(rowData); - if (!copied) { + const rowData = JSON.stringify(parsedRow, null, 2); + const copied = await copyTextToClipboard(rowData); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; + } + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch { notifications.show({ color: 'red', message: CLIPBOARD_ERROR_MESSAGE, }); - return; } - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); }; const copyRowUrl = async () => { diff --git a/packages/app/src/utils/__tests__/clipboard.test.ts b/packages/app/src/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000000..a9fba724f9 --- /dev/null +++ b/packages/app/src/utils/__tests__/clipboard.test.ts @@ -0,0 +1,131 @@ +import { copyTextToClipboard } from '../clipboard'; + +describe('copyTextToClipboard', () => { + const originalClipboard = navigator.clipboard; + const originalExecCommand = document.execCommand; + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); + document.execCommand = originalExecCommand; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); + + it('uses the Clipboard API when it is available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await expect(copyTextToClipboard('hello')).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith('hello'); + }); + + it('falls back to a textarea copy when the Clipboard API is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(true); + const initialChildCount = document.body.childElementCount; + + await expect(copyTextToClipboard('fallback text')).resolves.toBe(true); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(document.body.childElementCount).toBe(initialChildCount); + }); + + it('falls back to a textarea copy when the Clipboard API rejects', async () => { + const writeText = jest.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + document.execCommand = jest.fn().mockReturnValue(true); + + await expect(copyTextToClipboard('blocked')).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith('blocked'); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('removes the fallback textarea when execCommand throws', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn(() => { + throw new Error('copy failed'); + }); + const initialChildCount = document.body.childElementCount; + + await expect(copyTextToClipboard('throwing copy')).resolves.toBe(false); + + expect(document.body.childElementCount).toBe(initialChildCount); + }); + + it('restores the previous selection after the textarea fallback', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(true); + const selectedText = document.createTextNode('selected text'); + const container = document.createElement('div'); + container.appendChild(selectedText); + document.body.appendChild(container); + const range = document.createRange(); + range.selectNodeContents(selectedText); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + await expect(copyTextToClipboard('copy text')).resolves.toBe(true); + + expect(selection?.rangeCount).toBe(1); + expect(selection?.getRangeAt(0).toString()).toBe('selected text'); + }); + + it('still reports the copy result if selection restoration fails', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(true); + const selectedText = document.createTextNode('selected text'); + const container = document.createElement('div'); + container.appendChild(selectedText); + document.body.appendChild(container); + const range = document.createRange(); + range.selectNodeContents(selectedText); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const addRange = jest + .spyOn(selection!, 'addRange') + .mockImplementation(() => { + throw new Error('range detached'); + }); + + await expect(copyTextToClipboard('copy text')).resolves.toBe(true); + + expect(addRange).toHaveBeenCalled(); + }); + + it('reports failure when both copy methods are unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(false); + + await expect(copyTextToClipboard('nope')).resolves.toBe(false); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); +}); diff --git a/packages/app/src/utils/clipboard.test.ts b/packages/app/src/utils/clipboard.test.ts deleted file mode 100644 index c50dde5211..0000000000 --- a/packages/app/src/utils/clipboard.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { copyTextToClipboard } from './clipboard'; - -describe('copyTextToClipboard', () => { - const originalClipboard = navigator.clipboard; - const originalExecCommand = document.execCommand; - - afterEach(() => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: originalClipboard, - }); - document.execCommand = originalExecCommand; - document.body.innerHTML = ''; - jest.restoreAllMocks(); - }); - - it('uses the Clipboard API when it is available', async () => { - const writeText = jest.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - - await expect(copyTextToClipboard('hello')).resolves.toBe(true); - - expect(writeText).toHaveBeenCalledWith('hello'); - }); - - it('falls back to a textarea copy when the Clipboard API is unavailable', async () => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: undefined, - }); - document.execCommand = jest.fn().mockReturnValue(true); - - await expect(copyTextToClipboard('fallback text')).resolves.toBe(true); - - expect(document.execCommand).toHaveBeenCalledWith('copy'); - expect(document.querySelector('textarea')).toBeNull(); - }); - - it('does not fall back when the Clipboard API rejects', async () => { - const writeText = jest.fn().mockRejectedValue(new Error('denied')); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - document.execCommand = jest.fn().mockReturnValue(true); - - await expect(copyTextToClipboard('blocked')).resolves.toBe(false); - - expect(writeText).toHaveBeenCalledWith('blocked'); - expect(document.execCommand).not.toHaveBeenCalled(); - }); - - it('removes the fallback textarea when execCommand throws', async () => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: undefined, - }); - document.execCommand = jest.fn(() => { - throw new Error('copy failed'); - }); - - await expect(copyTextToClipboard('throwing copy')).resolves.toBe(false); - - expect(document.querySelector('textarea')).toBeNull(); - }); - - it('reports failure when both copy methods are unavailable', async () => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: undefined, - }); - document.execCommand = jest.fn().mockReturnValue(false); - - await expect(copyTextToClipboard('nope')).resolves.toBe(false); - }); -}); diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts index c1c0145093..1333823afa 100644 --- a/packages/app/src/utils/clipboard.ts +++ b/packages/app/src/utils/clipboard.ts @@ -7,7 +7,7 @@ export async function copyTextToClipboard(text: string): Promise { await navigator.clipboard.writeText(text); return true; } catch { - return false; + return copyTextWithTextarea(text); } } @@ -36,6 +36,10 @@ function copyTextWithTextarea(text: string): boolean { const selection = document.getSelection(); const previousRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const previousActiveElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; document.body.appendChild(textArea); textArea.select(); @@ -48,6 +52,9 @@ function copyTextWithTextarea(text: string): boolean { copied = false; } finally { document.body.removeChild(textArea); + if (previousActiveElement && document.contains(previousActiveElement)) { + previousActiveElement.focus({ preventScroll: true }); + } if (previousRange && selection) { try { selection.removeAllRanges();