mirror of
https://github.com/unslothai/unsloth
synced 2026-04-21 13:37:39 +00:00
Studio: Polish API key copy button and harden async clipboard fallback (#5006)
* fix: polish clipboard style and fix async clipboard path
* Use copyToClipboardAsync in CopyButton for Safari fallback
CopyButton was calling navigator.clipboard.writeText directly,
bypassing the execCommand fallback added in this same PR. Switch
to copyToClipboardAsync which tries execCommand first (Safari
user-gesture requirement) then falls back to the async clipboard API.
* Fix copyToClipboard sync contract regression and improve async path
- Restore copyToClipboard() to return only the execCommand result,
preserving the boolean contract that 7 existing callers depend on
to gate their "Copied!" UI state. The fire-and-forget async fallback
was returning true before the promise resolved, causing false success.
- Add document.body null guard to copyWithExecCommand for SSR safety.
- Reorder copyToClipboardAsync to try the async Clipboard API first,
avoiding unnecessary DOM/focus overhead in Radix focus-trapped dialogs
where execCommand always fails anyway.
* Restore queryCommandSupported guard and fix async catch path
- Restore the queryCommandSupported("copy") guard in copyToClipboard()
to match the original contract exactly: when execCommand is entirely
unsupported, fall through to fire-and-forget async clipboard write.
- Fix copyToClipboardAsync catch block: after navigator.clipboard.writeText
rejects, the user-gesture frame is gone, so execCommand will also fail.
Return false from catch instead of falling through. The execCommand
fallback at the bottom only runs when the Clipboard API is absent
(still in user-gesture frame).
* Restore execCommand fallback in copyToClipboardAsync catch path
The catch block was returning false after clipboard API rejection,
based on the incorrect premise that the user-gesture frame is lost
after an await. Per the HTML spec, transient user activation IS
preserved through promise microtask chains. The real reason
execCommand fails in the Radix dialog is the focus trap intercepting
textarea.focus(), not gesture loss.
For non-dialog callers, execCommand can still succeed after a
clipboard rejection. Inside a Radix modal, execCommand returns
false harmlessly (focus trap blocks it).
* Harden textarea fallback for mobile and continue to async path on failure
---------
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
This commit is contained in:
parent
97eafd999e
commit
bfa17330bd
2 changed files with 72 additions and 29 deletions
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { copyToClipboardAsync } from "@/lib/copy-to-clipboard";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
Copy01Icon,
|
||||
|
|
@ -104,20 +104,29 @@ function CopyButton({ text }: { text: string }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!copyToClipboard(text)) return;
|
||||
const handleCopy = async () => {
|
||||
if (!(await copyToClipboardAsync(text))) return;
|
||||
setCopied(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} className="shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md text-muted-foreground hover:text-foreground",
|
||||
copied && "text-emerald-600 hover:text-emerald-600",
|
||||
)}
|
||||
aria-label={copied ? "Copied API key" : "Copy API key"}
|
||||
title={copied ? "Copied" : "Copy"}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
className={cn("size-3.5 mr-1.5", copied && "text-emerald-600")}
|
||||
className="size-4"
|
||||
/>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,42 +6,76 @@
|
|||
* Uses a synchronous textarea + execCommand fallback so the copy runs in the
|
||||
* same user gesture as the click (required by Safari's clipboard security).
|
||||
*/
|
||||
function copyWithExecCommand(text: string): boolean {
|
||||
if (typeof document === "undefined" || !document.body) return false;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.readOnly = true;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.fontSize = "12pt";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
document.body.removeChild(textarea);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string): boolean {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Synchronous fallback: works in Safari/Mac when clipboard API fails
|
||||
// because it runs entirely within the user gesture (click) stack.
|
||||
if (document.queryCommandSupported?.("copy") !== false) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.select();
|
||||
try {
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
document.body.removeChild(textarea);
|
||||
return false;
|
||||
}
|
||||
if (typeof document !== "undefined" && document.queryCommandSupported?.("copy") !== false) {
|
||||
if (copyWithExecCommand(text)) return true;
|
||||
}
|
||||
|
||||
// Modern API only when fallback not available (e.g. non-browser)
|
||||
// Async fallback for environments where execCommand is entirely unsupported
|
||||
// but the Clipboard API is available (rare; kept for original contract parity).
|
||||
if (typeof navigator?.clipboard?.writeText === "function") {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function copyToClipboardAsync(text: string): Promise<boolean> {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefer the async Clipboard API: avoids focus disruption in Radix
|
||||
// focus-trapped dialogs where execCommand always fails.
|
||||
if (typeof navigator?.clipboard?.writeText === "function") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Clipboard API rejected (e.g. NotAllowedError, permission policy).
|
||||
// User activation is still valid through promise chains per spec, so
|
||||
// execCommand can succeed for callers outside focus-trapped dialogs.
|
||||
// Inside a Radix modal the focus trap will block textarea.focus() and
|
||||
// execCommand returns false harmlessly.
|
||||
return copyWithExecCommand(text);
|
||||
}
|
||||
}
|
||||
|
||||
// No Clipboard API (older browser / non-secure context): still in the
|
||||
// original user-gesture frame, so execCommand can work.
|
||||
return copyWithExecCommand(text);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue