forked from bots-garden/ori
| Add copy-paste functionality for code blocks and completion widgets | 1 | /** |
| 2 | * CopyButton provides a reusable copy-to-clipboard button with visual feedback. | |
| 3 | * Shows a clipboard icon that changes to a checkmark on successful copy. | |
| 4 | * | |
| 5 | * @example | |
| 6 | * <CopyButton content="const x = 42;" label="Copy code" /> | |
| 7 | */ | |
| 8 | ||
| 9 | import { useState } from "react"; | |
| 10 | ||
| 11 | interface CopyButtonProps { | |
| 12 | content: string; | |
| 13 | label?: string; | |
| 14 | className?: string; | |
| 15 | } | |
| 16 | ||
| 17 | export function CopyButton({ | |
| 18 | content, | |
| 19 | label = "Copy", | |
| 20 | className = "", | |
| 21 | }: CopyButtonProps) { | |
| 22 | const [copied, setCopied] = useState(false); | |
| 23 | ||
| 24 | const handleCopy = async () => { | |
| 25 | try { | |
| 26 | // Use modern Clipboard API with fallback | |
| 27 | if (navigator.clipboard && window.isSecureContext) { | |
| 28 | await navigator.clipboard.writeText(content); | |
| 29 | } else { | |
| 30 | // Fallback for older browsers or non-HTTPS contexts | |
| 31 | const textArea = document.createElement("textarea"); | |
| 32 | textArea.value = content; | |
| 33 | textArea.style.position = "fixed"; | |
| 34 | textArea.style.left = "-999999px"; | |
| 35 | textArea.style.top = "-999999px"; | |
| 36 | document.body.appendChild(textArea); | |
| 37 | textArea.focus(); | |
| 38 | textArea.select(); | |
| 39 | document.execCommand("copy"); | |
| 40 | textArea.remove(); | |
| 41 | } | |
| 42 | ||
| 43 | setCopied(true); | |
| 44 | setTimeout(() => setCopied(false), 2000); | |
| 45 | } catch (err) { | |
| 46 | console.error("Failed to copy:", err); | |
| 47 | } | |
| 48 | }; | |
| 49 | ||
| 50 | return ( | |
| 51 | <button | |
| 52 | type="button" | |
| 53 | className={`copy-btn ${copied ? "copy-btn-copied" : ""} ${className}`} | |
| 54 | onClick={handleCopy} | |
| 55 | aria-label={copied ? "Copied!" : label} | |
| 56 | title={copied ? "Copied!" : label} | |
| 57 | > | |
| 58 | {copied ? ( | |
| 59 | <svg | |
| 60 | width="16" | |
| 61 | height="16" | |
| 62 | viewBox="0 0 16 16" | |
| 63 | fill="none" | |
| 64 | stroke="currentColor" | |
| 65 | strokeWidth="2" | |
| 66 | strokeLinecap="round" | |
| 67 | strokeLinejoin="round" | |
| 68 | aria-hidden="true" | |
| 69 | > | |
| 70 | <polyline points="4,8 7,11 12,5" /> | |
| 71 | </svg> | |
| 72 | ) : ( | |
| 73 | <svg | |
| 74 | width="16" | |
| 75 | height="16" | |
| 76 | viewBox="0 0 16 16" | |
| 77 | fill="none" | |
| 78 | stroke="currentColor" | |
| 79 | strokeWidth="1.5" | |
| 80 | strokeLinecap="round" | |
| 81 | strokeLinejoin="round" | |
| 82 | aria-hidden="true" | |
| 83 | > | |
| 84 | <rect x="5" y="5" width="9" height="9" rx="1" /> | |
| 85 | <path d="M3 11V3a1 1 0 0 1 1-1h8" /> | |
| 86 | </svg> | |
| 87 | )} | |
| 88 | </button> | |
| 89 | ); | |
| 90 | } |