diff --git a/martin/martin-ui/__tests__/components/catalogs/__snapshots__/sprite.test.tsx.snap b/martin/martin-ui/__tests__/components/catalogs/__snapshots__/sprite.test.tsx.snap index d91947e58..183e9f7fe 100644 --- a/martin/martin-ui/__tests__/components/catalogs/__snapshots__/sprite.test.tsx.snap +++ b/martin/martin-ui/__tests__/components/catalogs/__snapshots__/sprite.test.tsx.snap @@ -78,7 +78,7 @@ exports[`SpriteCatalog Component > matches snapshot for loaded state with mock d map-icons
matches snapshot for loaded state with mock d transportation
matches snapshot for loaded state with mock d ui-elements
renders with retry button when onRetry is
{code} @@ -153,7 +153,6 @@ function MyMap() { /> ); }`; - const id = useId(); return ( !v && onCloseAction()} open={true}> @@ -227,7 +226,7 @@ function MyMap() { - +
@@ -243,7 +242,7 @@ function MyMap() { - +
@@ -259,7 +258,7 @@ function MyMap() { - +
@@ -279,7 +278,7 @@ function MyMap() { - +
@@ -295,7 +294,7 @@ function MyMap() { - +
@@ -311,7 +310,7 @@ function MyMap() { - +
diff --git a/martin/martin-ui/src/components/error/error-state.tsx b/martin/martin-ui/src/components/error/error-state.tsx index 674a4900a..da2d10e59 100644 --- a/martin/martin-ui/src/components/error/error-state.tsx +++ b/martin/martin-ui/src/components/error/error-state.tsx @@ -111,8 +111,14 @@ export function InlineErrorState({ {message} {onRetry && ( - )} diff --git a/martin/martin-ui/src/components/sprite/SpriteCanvas.tsx b/martin/martin-ui/src/components/sprite/SpriteCanvas.tsx index 11f4b70ca..2b8eaecc8 100644 --- a/martin/martin-ui/src/components/sprite/SpriteCanvas.tsx +++ b/martin/martin-ui/src/components/sprite/SpriteCanvas.tsx @@ -1,7 +1,7 @@ import { Copy } from 'lucide-react'; import { useEffect, useRef } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { useToast } from '@/hooks/use-toast'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import type { SpriteMeta } from './SpriteCache'; type SpriteCanvasProps = { @@ -13,24 +13,12 @@ type SpriteCanvasProps = { const SpriteCanvas = ({ meta, image, label, previewMode = false }: SpriteCanvasProps) => { const canvasRef = useRef(null); - const { toast } = useToast(); + // not using copied since on-click the tooltip closes + const { copy } = useCopyToClipboard({ + successMessage: `Sprite ID "${label}" copied to clipboard`, + }); - const handleClick = async () => { - try { - await navigator.clipboard.writeText(label); - toast({ - description: `Sprite ID "${label}" copied to clipboard`, - title: 'Copied!', - }); - } catch (err) { - console.error('Failed to copy sprite ID:', err); - toast({ - description: 'Failed to copy sprite ID to clipboard', - title: 'Error', - variant: 'destructive', - }); - } - }; + const handleClick = () => copy(label); useEffect(() => { const canvas = canvasRef.current; @@ -68,8 +56,8 @@ const SpriteCanvas = ({ meta, image, label, previewMode = false }: SpriteCanvasP {label}
-
- Click to copy +
+ Click to copy

@@ -108,8 +96,8 @@ const SpriteCanvas = ({ meta, image, label, previewMode = false }: SpriteCanvasP -
- Click to copy +
+ Click to copy
diff --git a/martin/martin-ui/src/components/ui/copy-link-button.tsx b/martin/martin-ui/src/components/ui/copy-link-button.tsx index 55baefe6e..044ef38c2 100644 --- a/martin/martin-ui/src/components/ui/copy-link-button.tsx +++ b/martin/martin-ui/src/components/ui/copy-link-button.tsx @@ -1,19 +1,9 @@ -import { Clipboard } from 'lucide-react'; -import * as React from 'react'; -import { useToast } from '@/hooks/use-toast'; +import { Clipboard, ClipboardCheck } from 'lucide-react'; +import type * as React from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; +import { cn } from '@/lib/utils'; import { Button } from './button'; -/** - * Props for CopyLinkButton - * @param link The string to copy to clipboard (required) - * @param children Optional label or content for the button (defaults to "Copy Link") - * @param className Optional additional class names for the button - * @param toastMessage Optional custom toast message (defaults to "Link copied!") - * @param size Button size (defaults to "sm") - * @param variant Button variant (defaults to "outline") - * @param iconPosition "left" or "right" (defaults to "left") - * @param ...props Any other Button props - */ export interface CopyLinkButtonProps extends React.ComponentProps { link: string; children?: React.ReactNode; @@ -21,7 +11,6 @@ export interface CopyLinkButtonProps extends React.ComponentProps toastMessage?: string; size?: 'default' | 'sm' | 'lg' | 'icon'; variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; - iconPosition?: 'left' | 'right'; } export function CopyLinkButton({ @@ -31,40 +20,37 @@ export function CopyLinkButton({ toastMessage = 'Link copied!', size = 'sm', variant = 'outline', - iconPosition = 'left', ...props }: CopyLinkButtonProps) { - const { toast } = useToast(); - const [copied, setCopied] = React.useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.preventDefault(); - try { - await navigator.clipboard.writeText(link); - setCopied(true); - toast({ description: toastMessage }); - setTimeout(() => setCopied(false), 3000); - } catch { - toast({ description: 'Failed to copy link', variant: 'destructive' }); - } - }; + const { copy, copied } = useCopyToClipboard({ successMessage: toastMessage }); return ( ); diff --git a/martin/martin-ui/src/components/ui/tooltip-copy-text.tsx b/martin/martin-ui/src/components/ui/tooltip-copy-text.tsx index b2f30371c..3e66f657d 100644 --- a/martin/martin-ui/src/components/ui/tooltip-copy-text.tsx +++ b/martin/martin-ui/src/components/ui/tooltip-copy-text.tsx @@ -1,41 +1,20 @@ import { Copy } from 'lucide-react'; -import { useToast } from '@/hooks/use-toast'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'; -/** - * Props for TooltipCopyText - * @param text The string to copy to clipboard (required) - * @param ...props Any other TooltipContent props - */ export interface TooltipCopyTextProps { text: string; } export function TooltipCopyText({ text, ...props }: TooltipCopyTextProps) { - const { toast } = useToast(); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(text); - toast({ - description: `"${text}"`, - title: 'Copied!', - }); - } catch (err) { - console.error(`Failed to copy "${text}"`, err); - toast({ - description: 'Failed to copy to clipboard', - title: 'Error', - variant: 'destructive', - }); - } - }; + // no copied and switching the icon since clicking immediately closes the tooltip + const { copy } = useCopyToClipboard({ successMessage: `"${text}"` }); return ( copy(text)} type="button" > {text} @@ -43,8 +22,8 @@ export function TooltipCopyText({ text, ...props }: TooltipCopyTextProps) {
{text}
-
- Click to copy +
+ Click to copy
diff --git a/martin/martin-ui/src/hooks/use-copy-to-clipboard.ts b/martin/martin-ui/src/hooks/use-copy-to-clipboard.ts new file mode 100644 index 000000000..2872796cf --- /dev/null +++ b/martin/martin-ui/src/hooks/use-copy-to-clipboard.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useToast } from '@/hooks/use-toast'; +import { copyToClipboard } from '@/lib/utils'; + +const RESET_DELAY = 2000; +const ERROR_MESSAGE = 'Failed to copy to clipboard'; + +interface UseCopyToClipboardOptions { + successMessage?: string; +} + +interface UseCopyToClipboardReturn { + copied: boolean; + copiedValue: string | null; + copy: (text: string, customSuccessMessage?: string) => Promise; +} + +/** Hook for clipboard operations with toast notifications and auto-reset state. */ +export function useCopyToClipboard( + options: UseCopyToClipboardOptions = {}, +): UseCopyToClipboardReturn { + const { successMessage = 'Copied!' } = options; + const { toast } = useToast(); + const [copied, setCopied] = useState(false); + const [copiedValue, setCopiedValue] = useState(null); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const copy = useCallback( + async (text: string, customSuccessMessage?: string): Promise => { + try { + await copyToClipboard(text); + setCopied(true); + setCopiedValue(text); + + toast({ + description: customSuccessMessage ?? successMessage, + title: 'Copied!', + }); + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setCopied(false); + setCopiedValue(null); + timeoutRef.current = null; + }, RESET_DELAY); + + return true; + } catch (err) { + console.error('Failed to copy to clipboard:', err); + toast({ + description: ERROR_MESSAGE, + title: 'Error', + variant: 'destructive', + }); + return false; + } + }, + [successMessage, toast], + ); + + return { copied, copiedValue, copy }; +} diff --git a/martin/martin-ui/src/lib/utils.ts b/martin/martin-ui/src/lib/utils.ts index 9831f07bc..07cc77771 100644 --- a/martin/martin-ui/src/lib/utils.ts +++ b/martin/martin-ui/src/lib/utils.ts @@ -5,6 +5,48 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** + * Copies text to the clipboard, with a fallback for non-HTTPS/non-localhost contexts. + * + * The modern navigator.clipboard API only works in secure contexts (HTTPS or localhost). + * When Martin starts at http://0.0.0.0:3000, the clipboard API won't work, so we fall + * back to the legacy document.execCommand('copy') method. + * + * @param text The text to copy to the clipboard + * @returns A promise that resolves when the text is copied, or rejects on failure + */ +export async function copyToClipboard(text: string): Promise { + // Try the modern clipboard API first (works in secure contexts) + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall through to legacy method + } + } + + // Fallback for non-HTTPS/non-localhost contexts (e.g., http://0.0.0.0:3000) + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + textarea.style.pointerEvents = 'none'; + textarea.setAttribute('aria-hidden', 'true'); + document.body.appendChild(textarea); + textarea.focus(); + textarea.setSelectionRange(0, text.length); + try { + const success = document.execCommand('copy'); + if (!success) { + throw new Error('Copy command failed'); + } + } finally { + document.body.removeChild(textarea); + } +} + /** * Formats a file size in bytes to a human-readable string * @param bytes The file size in bytes diff --git a/martin/martin-ui/vitest.setup.tsx b/martin/martin-ui/vitest.setup.tsx index fece7c1ea..015456513 100644 --- a/martin/martin-ui/vitest.setup.tsx +++ b/martin/martin-ui/vitest.setup.tsx @@ -1,4 +1,3 @@ -import { Rocket } from 'lucide-react'; import React from 'react'; import { vi } from 'vitest';