diff --git a/app/demo/copy-to-clipboard/page.tsx b/app/demo/copy-to-clipboard/page.tsx new file mode 100644 index 0000000..5047be1 --- /dev/null +++ b/app/demo/copy-to-clipboard/page.tsx @@ -0,0 +1,621 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + CopyButton, + CopyTextButton, + CopyIconButton, +} from "@/components/ui/copy-button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +// Note: Using Input component for textarea functionality +import { + copyToClipboard, + copyToClipboardWithRetry, + isCopySupported, + copyHelpers, + type CopyResult, +} from "@/lib/copy-to-clipboard"; +import { Check, Copy, Download, FileText, Globe, Code } from "lucide-react"; +// Toast is automatically available through the CopyButton components + +export default function CopyToClipboardDemo() { + const [customText, setCustomText] = useState("Hello, World!"); + const [copyResult, setCopyResult] = useState(""); + const [isSupported, setIsSupported] = useState(null); + const [testFiles] = useState([ + { name: "index.js", content: 'console.log("Hello, World!");' }, + { + name: "utils.ts", + content: "export const formatDate = (date: Date) => date.toISOString();", + }, + { name: "README.md", content: "# My Project\n\nThis is a sample project." }, + ]); + + // Check copy support on client side only + useEffect(() => { + setIsSupported(isCopySupported()); + }, []); + + const handleCustomCopy = async () => { + const result = await copyToClipboard(customText); + setCopyResult( + result.success ? "✅ Successfully copied!" : `❌ Failed: ${result.error}` + ); + }; + + const handleRetryCopy = async () => { + const result = await copyToClipboardWithRetry(customText, 3); + setCopyResult( + result.success + ? "✅ Successfully copied with retry!" + : `❌ Failed after retries: ${result.error}` + ); + }; + + const handleCopyResult = (result: CopyResult) => { + setCopyResult( + result.success ? "✅ Copy successful!" : `❌ Copy failed: ${result.error}` + ); + }; + + const handleCopyGistUrl = async () => { + const result = await copyHelpers.copyGistUrl("abc123", "secret-key"); + setCopyResult( + result.success ? "✅ Gist URL copied!" : `❌ Failed: ${result.error}` + ); + }; + + const handleCopyMultipleFiles = async () => { + const result = await copyHelpers.copyMultipleFiles(testFiles); + setCopyResult( + result.success ? "✅ All files copied!" : `❌ Failed: ${result.error}` + ); + }; + + const handleCopyFileWithComment = async () => { + const result = await copyHelpers.copyFileContent( + testFiles[0].content, + testFiles[0].name + ); + setCopyResult( + result.success + ? "✅ File with comment copied!" + : `❌ Failed: ${result.error}` + ); + }; + + return ( +
+
+
+

+ Copy to Clipboard Demo +

+

+ Comprehensive demonstration of copy-to-clipboard functionality with + different components, utilities, and error handling. +

+
+ Copy Support: + {isSupported === null ? ( + Checking... + ) : isSupported ? ( + + + Supported + + ) : ( + Not Supported + )} +
+
+ + + + Components + Utilities + Helpers + Examples + + + +
+ {/* Basic Copy Button */} + + + CopyButton Component + + Icon-only copy button with visual feedback and toast + notifications. + + + +
+ +
+ setCustomText(e.target.value)} + placeholder="Enter text to copy..." + /> + +
+
+ +
+

Variants & Sizes:

+
+ + + + + +
+
+ +
+

+ Configuration Options: +

+
+ + + No toast notification + +
+
+ + + No visual feedback + +
+
+ + + With retry logic + +
+
+
+
+ + {/* Copy Text Button */} + + + CopyTextButton Component + + Button with icon and text label for inline copy actions. + + + +
+

Default Usage:

+ +
+ +
+

Custom Labels:

+
+ + + +
+
+ +
+

File Examples:

+
+ {testFiles.map((file) => ( +
+
+ + + {file.name} + +
+ +
+ ))} +
+
+
+
+ + {/* Copy Icon Button */} + + + CopyIconButton Component + + Compact icon-only button for space-constrained layouts. + + + +
+

In File Headers:

+
+ {testFiles.map((file) => ( +
+
+ + {file.name} +
+
+ + +
+
+ ))} +
+
+ +
+

URL Display:

+
+
+ + https://ghostpaste.dev/g/abc123 + + #key=secret-key + + +
+ +
+
+
+
+ + {/* Result Display */} + + + Copy Results + + Real-time feedback from copy operations. + + + +
+ {copyResult || "No recent copy operations"} +
+
+
+
+
+ + +
+ + + Direct API Usage + + Using the copy utility functions directly. + + + +
+ + setCustomText(e.target.value)} + placeholder="Enter text to copy..." + /> +
+ +
+ + +
+
+
+ + + + Copy Support Detection + + Check if copy functionality is available. + + + +
+

+ Browser Support: +

+
    +
  • + • Modern Clipboard API:{" "} + {typeof navigator !== "undefined" && navigator.clipboard + ? "✅" + : "❌"} +
  • +
  • + • Secure Context:{" "} + {typeof window !== "undefined" && window.isSecureContext + ? "✅" + : "❌"} +
  • +
  • + • Fallback Support:{" "} + {typeof document !== "undefined" && + document.queryCommandSupported?.("copy") + ? "✅" + : "❌"} +
  • +
  • + • Overall Support:{" "} + {isSupported === null + ? "⏳" + : isSupported + ? "✅" + : "❌"} +
  • +
+
+
+
+
+
+ + +
+ + + GhostPaste Helpers + + Specialized copy functions for GhostPaste content. + + + + + + + + + + + + Test Files + + Sample files for testing copy operations. + + + +
+ {testFiles.map((file) => ( +
+

{file.name}

+
+                          {file.content}
+                        
+
+ ))} +
+
+
+
+
+ + +
+ + + Real-World Examples + + How copy functionality appears in actual GhostPaste + components. + + + + {/* Share Dialog Example */} +
+

Share Dialog Example

+
+

+ Your secure link: +

+
+
+ + https://ghostpaste.dev/g/demo123 + + #key=demo-secret-key + + +
+ +
+
+ + +
+
+
+ + {/* GistViewer Example */} +
+

GistViewer Example

+
+
+
+ + example.js +
+
+ + +
+
+
+
+                          {`function greet(name) {
+  return \`Hello, \${name}!\`;
+}
+
+console.log(greet("World"));`}
+                        
+
+
+
+
+
+
+
+
+ +
+

Features Demonstrated

+
+
+

✨ Component Features

+
    +
  • + • Three component variants (CopyButton, CopyTextButton, + CopyIconButton) +
  • +
  • • Toast notifications with Sonner integration
  • +
  • • Visual feedback with icon switching
  • +
  • • Configurable success/error messages
  • +
  • • Loading states and error handling
  • +
  • • Accessibility support (ARIA labels, keyboard nav)
  • +
+
+
+

🛠️ Utility Features

+
    +
  • • Modern Clipboard API with fallback
  • +
  • • Retry logic for unreliable connections
  • +
  • • Browser support detection
  • +
  • • Specialized helpers for GhostPaste content
  • +
  • • Multi-file copy with separators
  • +
  • • File content with filename comments
  • +
+
+
+
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 590c04f..4619dfc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { ThemeProvider } from "@/components/theme-provider"; import { Header } from "@/components/header"; import { ErrorBoundary } from "@/components/error-boundary"; +import { Toaster } from "sonner"; import "./globals.css"; const geistSans = Geist({ @@ -43,6 +44,7 @@ export default function RootLayout({ {children} + diff --git a/components/gist-viewer.tsx b/components/gist-viewer.tsx index 3b44100..efa3671 100644 --- a/components/gist-viewer.tsx +++ b/components/gist-viewer.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; +import { CopyIconButton } from "@/components/ui/copy-button"; import { CodeEditor } from "@/components/ui/code-editor"; import { Copy, Download, FileText } from "lucide-react"; import { useTheme } from "next-themes"; @@ -12,6 +13,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { copyHelpers } from "@/lib/copy-to-clipboard"; import type { File } from "@/types"; export interface GistViewerProps { @@ -24,12 +26,11 @@ export function GistViewer({ files, className }: GistViewerProps) { const [wordWrap, setWordWrap] = useState(false); const { resolvedTheme } = useTheme(); - const handleCopyFile = async (content: string) => { + const handleCopyAll = async () => { try { - await navigator.clipboard.writeText(content); - // TODO: Show toast notification + await copyHelpers.copyMultipleFiles(files); } catch (error) { - console.error("Failed to copy:", error); + console.error("Failed to copy all files:", error); } }; @@ -82,15 +83,26 @@ export function GistViewer({ files, className }: GistViewerProps) { Word Wrap: {wordWrap ? "On" : "Off"} - +
+ + +
{/* Files List - Vertical Layout */} @@ -102,7 +114,7 @@ export function GistViewer({ files, className }: GistViewerProps) { theme={resolvedTheme === "dark" ? "dark" : "light"} showLineNumbers={showLineNumbers} wordWrap={wordWrap} - onCopy={() => handleCopyFile(file.content)} + onCopy={() => {}} onDownload={() => handleDownloadFile(file)} /> ))} @@ -126,7 +138,7 @@ function FileContent({ theme, showLineNumbers, wordWrap, - onCopy, + onCopy: _onCopy, onDownload, }: FileContentProps) { return ( @@ -141,16 +153,13 @@ function FileContent({
- + successMessage={`${file.name} copied to clipboard!`} + /> Copy to clipboard diff --git a/components/share-dialog.tsx b/components/share-dialog.tsx index 1067d4c..293d1ed 100644 --- a/components/share-dialog.tsx +++ b/components/share-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; import { Button } from "@/components/ui/button"; +import { CopyIconButton, CopyTextButton } from "@/components/ui/copy-button"; import { Dialog, DialogClose, @@ -11,8 +11,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Check, Copy, Download, AlertTriangle } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { Check, Download, AlertTriangle } from "lucide-react"; +import { type CopyResult } from "@/lib/copy-to-clipboard"; export interface ShareDialogProps { /** Whether the dialog is open */ @@ -37,45 +37,14 @@ export function ShareDialog({ onCopy, onDownload, }: ShareDialogProps) { - const [copySuccess, setCopySuccess] = useState(false); - // Split the URL at the fragment for visual display const urlParts = shareUrl.split("#"); const baseUrl = urlParts[0]; const fragment = urlParts[1] ? `#${urlParts[1]}` : ""; - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(shareUrl); - setCopySuccess(true); + const handleCopyResult = (result: CopyResult) => { + if (result.success) { onCopy?.(); - - // Reset success state after 2 seconds - setTimeout(() => setCopySuccess(false), 2000); - } catch (error) { - console.error("Failed to copy URL:", error); - // Fallback for older browsers - fallbackCopy(); - } - }; - - const fallbackCopy = () => { - const textArea = document.createElement("textarea"); - textArea.value = shareUrl; - textArea.style.position = "absolute"; - textArea.style.left = "-999999px"; - document.body.appendChild(textArea); - textArea.select(); - - try { - document.execCommand("copy"); - setCopySuccess(true); - onCopy?.(); - setTimeout(() => setCopySuccess(false), 2000); - } catch (error) { - console.error("Fallback copy failed:", error); - } finally { - document.body.removeChild(textArea); } }; @@ -126,20 +95,13 @@ export function ShareDialog({ )}
- + /> @@ -174,26 +136,14 @@ export function ShareDialog({ Download as Text - + + ); +} + +/** + * Copy button specifically for inline text with icon and label + */ +export function CopyTextButton({ + text, + label = "Copy", + className, + ...props +}: Omit & { + label?: string; +}) { + return ( + + + {label} + + ); +} + +/** + * Icon-only copy button for compact spaces + */ +export function CopyIconButton({ + text, + className, + ...props +}: Omit) { + return ( + + ); +} diff --git a/lib/copy-to-clipboard.test.ts b/lib/copy-to-clipboard.test.ts new file mode 100644 index 0000000..c6f2094 --- /dev/null +++ b/lib/copy-to-clipboard.test.ts @@ -0,0 +1,534 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + copyToClipboard, + copyToClipboardWithRetry, + isCopySupported, + copyHelpers, +} from "./copy-to-clipboard"; + +// Mock console methods to avoid noise in tests +vi.spyOn(console, "warn").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +describe("copyToClipboard", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset DOM + document.body.innerHTML = ""; + + // Mock window.isSecureContext + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("input validation", () => { + it("should reject non-string input", async () => { + const result = await copyToClipboard(123 as any); + expect(result.success).toBe(false); + expect(result.error).toBe("Invalid input: text must be a string"); + }); + + it("should reject empty string", async () => { + const result = await copyToClipboard(""); + expect(result.success).toBe(false); + expect(result.error).toBe("Cannot copy empty text"); + }); + + it("should accept valid string input", async () => { + // Mock successful clipboard API + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const result = await copyToClipboard("test text"); + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledWith("test text"); + }); + }); + + describe("modern clipboard API", () => { + it("should use navigator.clipboard.writeText when available", async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const result = await copyToClipboard("test content"); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledWith("test content"); + }); + + it("should handle clipboard API errors and fallback", async () => { + const mockWriteText = vi + .fn() + .mockRejectedValue(new Error("Clipboard API failed")); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + // Mock successful execCommand fallback + const mockExecCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboard("test content"); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalled(); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + }); + + it("should fallback when not in secure context", async () => { + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: false, + }); + + const mockWriteText = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const mockExecCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboard("test content"); + + expect(result.success).toBe(true); + expect(mockWriteText).not.toHaveBeenCalled(); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + }); + }); + + describe("fallback method", () => { + beforeEach(() => { + // Disable modern clipboard API + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: undefined, + }); + }); + + it("should use execCommand fallback when clipboard API unavailable", async () => { + const mockExecCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboard("fallback test"); + + expect(result.success).toBe(true); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + }); + + it("should create and remove textarea element during fallback", async () => { + const mockExecCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const initialChildCount = document.body.children.length; + + await copyToClipboard("textarea test"); + + // Textarea should be removed after copy + expect(document.body.children.length).toBe(initialChildCount); + }); + + it("should handle execCommand failure", async () => { + const mockExecCommand = vi.fn().mockReturnValue(false); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboard("failing test"); + + expect(result.success).toBe(false); + expect(result.error).toBe("Copy command failed"); + }); + + it("should handle execCommand exceptions", async () => { + const mockExecCommand = vi.fn().mockImplementation(() => { + throw new Error("DOM exception"); + }); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboard("exception test"); + + expect(result.success).toBe(false); + expect(result.error).toBe("DOM exception"); + }); + }); + + describe("edge cases", () => { + it("should handle very long text", async () => { + const longText = "a".repeat(10000); + + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const result = await copyToClipboard(longText); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledWith(longText); + }); + + it("should handle text with special characters", async () => { + const specialText = "特殊文字 🚀 \n\t\"quotes\" 'single' "; + + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const result = await copyToClipboard(specialText); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledWith(specialText); + }); + }); +}); + +describe("copyToClipboardWithRetry", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should succeed on first attempt when copy works", async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const result = await copyToClipboardWithRetry("test"); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it("should retry on failure and eventually succeed", async () => { + const mockWriteText = vi + .fn() + .mockRejectedValueOnce(new Error("First attempt failed")) + .mockResolvedValue(undefined); + + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + // Mock execCommand to fail as well for first attempt + const mockExecCommand = vi + .fn() + .mockReturnValueOnce(false) + .mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboardWithRetry("retry test", 2); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledTimes(2); + }); + + it("should fail after max retries exceeded", async () => { + const mockWriteText = vi.fn().mockRejectedValue(new Error("Always fails")); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const mockExecCommand = vi.fn().mockReturnValue(false); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboardWithRetry("fail test", 1); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed after 2 attempts"); + expect(mockWriteText).toHaveBeenCalledTimes(2); + }); + + it("should use default retry count of 2", async () => { + const mockWriteText = vi.fn().mockRejectedValue(new Error("Always fails")); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + + const mockExecCommand = vi.fn().mockReturnValue(false); + Object.defineProperty(document, "execCommand", { + writable: true, + value: mockExecCommand, + }); + + const result = await copyToClipboardWithRetry("default retry test"); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed after 3 attempts"); + expect(mockWriteText).toHaveBeenCalledTimes(3); + }); +}); + +describe("isCopySupported", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return true when modern clipboard API is available", () => { + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: vi.fn() }, + }); + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: true, + }); + + expect(isCopySupported()).toBe(true); + }); + + it("should return false when not in secure context", () => { + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: vi.fn() }, + }); + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: false, + }); + + const mockQueryCommandSupported = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "queryCommandSupported", { + writable: true, + value: mockQueryCommandSupported, + }); + + expect(isCopySupported()).toBe(true); + }); + + it("should return true when execCommand is supported", () => { + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: undefined, + }); + + const mockQueryCommandSupported = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "queryCommandSupported", { + writable: true, + value: mockQueryCommandSupported, + }); + + expect(isCopySupported()).toBe(true); + expect(mockQueryCommandSupported).toHaveBeenCalledWith("copy"); + }); + + it("should return false when no copy support is available", () => { + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: undefined, + }); + + const mockQueryCommandSupported = vi.fn().mockReturnValue(false); + Object.defineProperty(document, "queryCommandSupported", { + writable: true, + value: mockQueryCommandSupported, + }); + + expect(isCopySupported()).toBe(false); + }); + + it("should handle queryCommandSupported exceptions", () => { + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: undefined, + }); + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: false, + }); + + // Make queryCommandSupported throw + Object.defineProperty(document, "queryCommandSupported", { + writable: true, + value: () => { + throw new Error("queryCommandSupported not supported"); + }, + }); + + expect(isCopySupported()).toBe(false); + }); +}); + +describe("copyHelpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock window.location + Object.defineProperty(window, "location", { + writable: true, + value: { + origin: "https://ghostpaste.dev", + }, + }); + + // Mock secure context + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: true, + }); + + // Mock successful clipboard + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + }); + + describe("copyGistUrl", () => { + it("should copy gist URL without key", async () => { + const result = await copyHelpers.copyGistUrl("abc123"); + + expect(result.success).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "https://ghostpaste.dev/g/abc123" + ); + }); + + it("should copy gist URL with encryption key", async () => { + const result = await copyHelpers.copyGistUrl("abc123", "secretkey"); + + expect(result.success).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "https://ghostpaste.dev/g/abc123#key=secretkey" + ); + }); + }); + + describe("copyFileContent", () => { + it("should copy content without filename", async () => { + const content = 'console.log("hello world");'; + const result = await copyHelpers.copyFileContent(content); + + expect(result.success).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(content); + }); + + it("should copy content with filename comment", async () => { + const content = 'console.log("hello world");'; + const result = await copyHelpers.copyFileContent(content, "app.js"); + + expect(result.success).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + '// app.js\nconsole.log("hello world");' + ); + }); + }); + + describe("copyMultipleFiles", () => { + it("should copy multiple files with separators", async () => { + const files = [ + { name: "index.js", content: 'console.log("index");' }, + { name: "utils.js", content: "export const util = () => {};" }, + ]; + + const result = await copyHelpers.copyMultipleFiles(files); + + expect(result.success).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + '// index.js\nconsole.log("index");\n\n---\n\n// utils.js\nexport const util = () => {};' + ); + }); + + it("should handle empty files array", async () => { + const result = await copyHelpers.copyMultipleFiles([]); + + expect(result.success).toBe(false); + expect(result.error).toBe("Cannot copy empty text"); + }); + + it("should handle single file", async () => { + const files = [{ name: "single.js", content: "const x = 1;" }]; + const result = await copyHelpers.copyMultipleFiles(files); + + expect(result.success).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "// single.js\nconst x = 1;" + ); + }); + }); +}); + +describe("integration scenarios", () => { + it("should handle typical ShareDialog usage", async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: true, + }); + + const gistUrl = "https://ghostpaste.dev/g/abc123#key=secret"; + const result = await copyToClipboard(gistUrl); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledWith(gistUrl); + }); + + it("should handle typical GistViewer file copy usage", async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { writeText: mockWriteText }, + }); + Object.defineProperty(window, "isSecureContext", { + writable: true, + value: true, + }); + + const fileContent = 'function example() {\n return "Hello World";\n}'; + const result = await copyHelpers.copyFileContent(fileContent, "example.js"); + + expect(result.success).toBe(true); + expect(mockWriteText).toHaveBeenCalledWith( + '// example.js\nfunction example() {\n return "Hello World";\n}' + ); + }); +}); diff --git a/lib/copy-to-clipboard.ts b/lib/copy-to-clipboard.ts new file mode 100644 index 0000000..81c25d7 --- /dev/null +++ b/lib/copy-to-clipboard.ts @@ -0,0 +1,184 @@ +/** + * Copy to clipboard utility functions for GhostPaste + * + * Provides consistent copy functionality with fallback support + * for older browsers and error handling. + */ + +export interface CopyResult { + success: boolean; + error?: string; +} + +/** + * Copy text to clipboard using modern Clipboard API with fallback + * + * @param text - Text to copy to clipboard + * @returns Promise - Result object with success status and optional error + */ +export async function copyToClipboard(text: string): Promise { + // Validate input + if (typeof text !== "string") { + return { + success: false, + error: "Invalid input: text must be a string", + }; + } + + if (text.length === 0) { + return { + success: false, + error: "Cannot copy empty text", + }; + } + + // Check if we're in a secure context and clipboard API is available + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return { success: true }; + } catch { + // Fall back to legacy method if clipboard API fails + return await copyToClipboardFallback(text); + } + } + + // Use fallback method for non-secure contexts or older browsers + return await copyToClipboardFallback(text); +} + +/** + * Fallback copy method using document.execCommand for older browsers + * + * @param text - Text to copy to clipboard + * @returns Promise - Result object with success status and optional error + */ +async function copyToClipboardFallback(text: string): Promise { + try { + // Create a temporary textarea element + const textarea = document.createElement("textarea"); + textarea.value = text; + + // Make textarea invisible but not display: none (which would prevent selection) + textarea.style.position = "fixed"; + textarea.style.left = "-999999px"; + textarea.style.top = "-999999px"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + // Add to DOM, select, copy, and remove + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + const successful = document.execCommand("copy"); + document.body.removeChild(textarea); + + if (successful) { + return { success: true }; + } else { + return { + success: false, + error: "Copy command failed", + }; + } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Unknown error occurred", + }; + } +} + +/** + * Copy text to clipboard with automatic retry logic + * + * @param text - Text to copy to clipboard + * @param maxRetries - Maximum number of retry attempts (default: 2) + * @returns Promise - Result object with success status and optional error + */ +export async function copyToClipboardWithRetry( + text: string, + maxRetries: number = 2 +): Promise { + let lastError: string | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const result = await copyToClipboard(text); + + if (result.success) { + return result; + } + + lastError = result.error; + + // Add small delay between retries + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + return { + success: false, + error: `Failed after ${maxRetries + 1} attempts. Last error: ${lastError}`, + }; +} + +/** + * Check if copy to clipboard is supported in the current environment + * + * @returns boolean - True if copy functionality is available + */ +export function isCopySupported(): boolean { + // Check for modern clipboard API + if (navigator.clipboard && window.isSecureContext) { + return true; + } + + // Check for legacy document.execCommand support + try { + return ( + document.queryCommandSupported && document.queryCommandSupported("copy") + ); + } catch { + return false; + } +} + +/** + * Copy specific content types commonly used in GhostPaste + */ +export const copyHelpers = { + /** + * Copy a complete gist URL with fragment + */ + async copyGistUrl(gistId: string, key?: string): Promise { + const baseUrl = `${window.location.origin}/g/${gistId}`; + const url = key ? `${baseUrl}#key=${key}` : baseUrl; + return copyToClipboard(url); + }, + + /** + * Copy file content with optional filename as comment + */ + async copyFileContent( + content: string, + filename?: string + ): Promise { + const textToCopy = filename ? `// ${filename}\n${content}` : content; + return copyToClipboard(textToCopy); + }, + + /** + * Copy multiple files as a combined string + */ + async copyMultipleFiles( + files: Array<{ name: string; content: string }> + ): Promise { + const combined = files + .map((file) => `// ${file.name}\n${file.content}`) + .join("\n\n---\n\n"); + return copyToClipboard(combined); + }, +}; diff --git a/package-lock.json b/package-lock.json index 985f515..52861ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "sonner": "^2.0.5", "tailwind-merge": "^3.3.0" }, "devDependencies": { @@ -13759,7 +13760,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -13769,7 +13770,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -15474,7 +15475,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -20815,6 +20816,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sonner": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.5.tgz", + "integrity": "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 3b2afd7..dc965d8 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "sonner": "^2.0.5", "tailwind-merge": "^3.3.0" }, "devDependencies": {