Add clipboard copy functionality that works over HTTP (#4446)

This commit is contained in:
Shuchang Zheng
2026-01-14 11:53:12 -08:00
committed by GitHub
parent e617ef9924
commit fed12acfc9
6 changed files with 73 additions and 39 deletions

View File

@@ -37,6 +37,7 @@ import {
newWssBaseUrl, newWssBaseUrl,
getRuntimeApiKey, getRuntimeApiKey,
} from "@/util/env"; } from "@/util/env";
import { copyText } from "@/util/copyText";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { RotateThrough } from "./RotateThrough"; import { RotateThrough } from "./RotateThrough";
@@ -698,14 +699,21 @@ function BrowserStream({
case "copied-text": { case "copied-text": {
const text = message.text; const text = message.text;
navigator.clipboard copyText(text)
.writeText(text) .then((success) => {
.then(() => { if (success) {
toast({ toast({
title: "Copied to Clipboard", title: "Copied to Clipboard",
description: description:
"The text has been copied to your clipboard. NOTE: copy-paste only works in the web page - not in the browser (like the address bar).", "The text has been copied to your clipboard. NOTE: copy-paste only works in the web page - not in the browser (like the address bar).",
}); });
} else {
toast({
variant: "destructive",
title: "Failed to write to Clipboard",
description: "The text could not be copied to your clipboard.",
});
}
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to write to clipboard:", err); console.error("Failed to write to clipboard:", err);

View File

@@ -2,15 +2,16 @@ import { useState } from "react";
import { CheckIcon, CopyIcon } from "@radix-ui/react-icons"; import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { copyText } from "@/util/copyText";
function CopyButton({ value }: { value: string }) { function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = () => { const handleCopy = async () => {
if (copied) { if (copied) {
return; return;
} }
window.navigator.clipboard.writeText(value); await copyText(value);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
}; };

View File

@@ -73,6 +73,7 @@ import {
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils"; import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils";
import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns"; import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns";
import { copyText } from "@/util/copyText";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer"; import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer";
@@ -196,8 +197,8 @@ function CopyAndExplainCode({ code }: { code: string }) {
function CopyText({ className, text }: { className?: string; text: string }) { function CopyText({ className, text }: { className?: string; text: string }) {
const [wasCopied, setWasCopied] = useState(false); const [wasCopied, setWasCopied] = useState(false);
function handleCopy(code: string) { async function handleCopy(code: string) {
navigator.clipboard.writeText(code); await copyText(code);
setWasCopied(true); setWasCopied(true);
setTimeout(() => setWasCopied(false), 2000); setTimeout(() => setWasCopied(false), 2000);
} }

View File

@@ -21,6 +21,7 @@ import {
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { copyText } from "@/util/copyText";
type Props = { type Props = {
onImport: (data: { onImport: (data: {
@@ -156,12 +157,20 @@ export function CurlImportDialog({ onImport, children }: Props) {
} }
}; };
const copyExample = (example: string) => { const copyExample = async (example: string) => {
navigator.clipboard.writeText(example); const success = await copyText(example);
toast({ if (success) {
title: "Copied", toast({
description: "Example copied to clipboard", title: "Copied",
}); description: "Example copied to clipboard",
});
} else {
toast({
title: "Error",
description: "Failed to copy example",
variant: "destructive",
});
}
}; };
return ( return (

View File

@@ -8,6 +8,7 @@ import {
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useState } from "react"; import { useState } from "react";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { copyText } from "@/util/copyText";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
// HTTP Method Badge Component // HTTP Method Badge Component
@@ -140,16 +141,16 @@ export function CopyToCurlButton({
}; };
const handleCopy = async () => { const handleCopy = async () => {
try { const curlCommand = generateCurlCommand();
const curlCommand = generateCurlCommand(); const success = await copyText(curlCommand);
await navigator.clipboard.writeText(curlCommand); if (success) {
setCopied(true); setCopied(true);
toast({ toast({
title: "Copied!", title: "Copied!",
description: "cURL command copied to clipboard", description: "cURL command copied to clipboard",
}); });
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (error) { } else {
toast({ toast({
title: "Error", title: "Error",
description: "Failed to copy cURL command", description: "Failed to copy cURL command",

View File

@@ -1,25 +1,39 @@
/** /**
* Progressively enhanced text copying * Progressively enhanced text copying with HTTP fallback
* https://web.dev/patterns/clipboard/copy-text * https://web.dev/patterns/clipboard/copy-text
*
* Uses navigator.clipboard when in a secure context (HTTPS),
* falls back to textarea + execCommand for HTTP contexts.
*/ */
async function copyText(text: string): Promise<void> { async function copyText(text: string): Promise<boolean> {
if ("clipboard" in navigator) { // Prefer navigator.clipboard when in a secure context
return navigator.clipboard.writeText(text); if (window.isSecureContext && navigator.clipboard?.writeText) {
} else { try {
const textArea = document.createElement("textarea"); await navigator.clipboard.writeText(text);
textArea.value = text; return true;
textArea.style.opacity = "0"; } catch (err) {
document.body.appendChild(textArea); // Fall through to fallback
textArea.focus();
textArea.select();
const success = document.execCommand("copy");
document.body.removeChild(textArea);
if (success) {
return Promise.resolve();
} else {
return Promise.reject();
} }
} }
// Fallback for HTTP or when clipboard API fails
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
// execCommand is deprecated but remains the only option for HTTP contexts
// where navigator.clipboard is unavailable. Browser support remains strong.
const success = document.execCommand("copy");
return success;
} finally {
document.body.removeChild(textArea);
}
} }
export { copyText }; export { copyText };