Files
backnotprop__plannotator/packages/ui/utils/clipboard.ts
Michael Ramos 463f6ed57c fix(ui): fall back to legacy copy in insecure browser contexts (#1174)
* fix(ui): fall back to legacy copy in insecure browser contexts

navigator.clipboard only exists in secure contexts. Remote mode serves
plain HTTP on a non-localhost host, so every bare
navigator.clipboard.writeText call threw TypeError and copy buttons
silently broke.

Add copyTextToClipboard(text): Promise<boolean> to
packages/ui/utils/clipboard.ts: it tries the async Clipboard API
(guarded against synchronous throws), falls back to the existing
copy-event plus execCommand path, reports success as a boolean, and
never throws. copyTextWithFallback now returns whether the copy
happened and accepts an optional focusOwner; copyTextPreservingFocus
keeps its exported signature and behavior unchanged.

Route all bare call sites through the helper, preserving each site's
UX: Copied states only flip on success, error toasts and console
errors remain for the failure case, fire-and-forget sites stay
fire-and-forget. GoalSetupSurface gains the fallback and keeps its
error surface for the all-strategies-failed case.

Add DOM-gated unit tests for the helper and register them in CI.

Closes #1173

* fix(ui): address clipboard fallback review findings

Review follow-ups for the insecure-context clipboard fallback:

1. Tag the fallback textarea with data-clipboard-fallback and whitelist
   it in PopoutDialog's ANNOTATION_SELECTORS so the transient focus
   shift during a fallback copy no longer closes popout dialogs
   (TablePopout copy buttons, CodeFilePopout copy contents).

2. Widen AnnotationPanel's onQuickCopy prop to Promise<void | boolean>.
   A false resolution now suppresses the Copied flash; void resolution
   stays success so existing hosts keep today's behavior. The editor
   quick-copy site returns the helper's boolean.

3. In copyTextWithFallback, only flag the copy-event path as success
   when clipboardData was present and setData actually ran, and only
   when execCommand also reported success. A null clipboardData no
   longer calls preventDefault, so the textarea retry still runs.

4. Add tests pinning that the fallback runs synchronously when
   navigator.clipboard is absent (execCommand fires before the call
   returns, keeping it inside the user-gesture window), that a copy
   event without clipboardData is not treated as success, and that the
   fallback textarea carries the PopoutDialog focus-out marker and is
   removed after the copy resolves.

5. GoalSetupSurface surfaces the real writeText rejection message when
   the Clipboard API exists but fails and the fallback also fails; the
   generic unavailable message is reserved for the API-absent case.
2026-08-03 10:14:34 -07:00

111 lines
3.6 KiB
TypeScript

function copyTextWithFallback(text: string, focusOwner?: HTMLElement): boolean {
const activeElement = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const selection = window.getSelection();
const savedRanges = selection
? Array.from({ length: selection.rangeCount }, (_, index) =>
selection.getRangeAt(index).cloneRange())
: [];
let copied = false;
const handleCopy = (event: ClipboardEvent) => {
// Without clipboardData there is nothing to write into; leave the native
// copy untouched (preventDefault would only suppress it) and stay
// unsuccessful so the textarea path below runs.
if (!event.clipboardData) return;
event.preventDefault();
event.clipboardData.setData('text/plain', text);
copied = true;
};
document.addEventListener('copy', handleCopy);
try {
// Success requires BOTH: our handler actually wrote the payload via
// setData, AND execCommand reported the copy command ran.
copied = document.execCommand('copy') && copied;
} catch {
copied = false;
} finally {
document.removeEventListener('copy', handleCopy);
}
if (!copied) {
const textarea = document.createElement('textarea');
textarea.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
// Marker so focus-sensitive surfaces (PopoutDialog's focus-out close
// guard) can recognize the transient fallback textarea and not treat the
// focus shift as leaving the dialog.
textarea.setAttribute('data-clipboard-fallback', 'true');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
try {
copied = document.execCommand('copy');
} catch {
// Clipboard access can be denied by the embedding browser. The caller
// still regains the same document focus and selection below.
copied = false;
}
textarea.remove();
}
if (activeElement?.isConnected) {
activeElement.focus({ preventScroll: true });
} else if (focusOwner?.isConnected) {
focusOwner.focus({ preventScroll: true });
}
if (selection && savedRanges.length > 0) {
selection.removeAllRanges();
savedRanges.forEach((range) => selection.addRange(range));
}
return copied;
}
/**
* Copy text without stealing focus or discarding the host document's native
* selection. Falls back to the copy event for restricted browser contexts.
*/
export function copyTextPreservingFocus(
text: string,
focusOwner: HTMLElement,
): void {
try {
const clipboardWrite = navigator.clipboard?.writeText(text);
if (clipboardWrite) {
void clipboardWrite.catch(() => {
copyTextWithFallback(text, focusOwner);
});
return;
}
} catch {
// Fall through when a browser exposes Clipboard but rejects access
// synchronously (for example, in a restricted embedded document).
}
copyTextWithFallback(text, focusOwner);
}
/**
* Copy text to the clipboard, falling back to the legacy copy-event /
* execCommand path when the async Clipboard API is unavailable (insecure
* contexts such as remote-mode plain HTTP) or rejects. Resolves `true` when
* a copy strategy succeeded and `false` otherwise. Never throws.
*/
export async function copyTextToClipboard(text: string): Promise<boolean> {
try {
const clipboardWrite = navigator.clipboard?.writeText(text);
if (clipboardWrite) {
await clipboardWrite;
return true;
}
} catch {
// Clipboard API absent, threw synchronously, or rejected — fall back to
// the legacy copy-event path below.
}
try {
return copyTextWithFallback(text);
} catch {
return false;
}
}