@@ -78,6 +78,11 @@ export interface CopyButtonProps extends ButtonProps {
7878 * Callback when copy fails
7979 */
8080 onCopyError ?: ( error : unknown ) => void ;
81+ /**
82+ * Whether the value is an image (base64 data URL)
83+ * @default false
84+ */
85+ isImage ?: boolean ;
8186}
8287
8388export const CopyButton = memo < CopyButtonProps > (
@@ -89,28 +94,165 @@ export const CopyButton = memo<CopyButtonProps>(
8994 toastText = "Copied to clipboard" ,
9095 onCopySuccess,
9196 onCopyError,
97+ isImage = false ,
9298 ...buttonProps
9399 } ) => {
94100 const { t } = useTranslation ( ) ;
95101 const { copy, copied } = useClipboard ( ) ;
96102 const [ hasCopyError , setHasCopyError ] = useState ( false ) ;
103+ const [ imageCopied , setImageCopied ] = useState ( false ) ;
97104
98105 // Reset error state after a timeout
99106 useEffect ( ( ) => {
100107 if ( hasCopyError ) {
101108 const timer = setTimeout ( ( ) => {
102109 setHasCopyError ( false ) ;
103- } , 2000 ) ;
110+ } , copiedTimeout ) ;
104111 return ( ) => clearTimeout ( timer ) ;
105112 }
106- } , [ hasCopyError ] ) ;
113+ } , [ hasCopyError , copiedTimeout ] ) ;
114+
115+ // Reset image copied state after a timeout
116+ useEffect ( ( ) => {
117+ if ( imageCopied ) {
118+ const timer = setTimeout ( ( ) => {
119+ setImageCopied ( false ) ;
120+ } , copiedTimeout ) ;
121+ return ( ) => clearTimeout ( timer ) ;
122+ }
123+ } , [ imageCopied , copiedTimeout ] ) ;
107124
108125 const handleCopy = useCallback ( ( ) => {
109- try {
110- if ( ! value ) {
111- throw new Error ( t ( 'no-value-to-copy' ) ) ;
112- }
126+ if ( isImage ) {
127+ // Handle image copying asynchronously
128+ ( async ( ) => {
129+ try {
130+ if ( ! value ) {
131+ throw new Error ( t ( 'no-value-to-copy' ) ) ;
132+ }
133+
134+ let blob : Blob ;
135+ let originalMimeType = 'image/png' ;
136+
137+ // Check if it's a data URL
138+ if ( value . startsWith ( 'data:' ) ) {
139+ // Extract MIME type from data URL
140+ const mimeMatch = value . match ( / ^ d a t a : ( [ ^ ; ] + ) / ) ;
141+ originalMimeType = mimeMatch ? mimeMatch [ 1 ] : 'image/png' ;
142+
143+ // Convert data URL to blob
144+ const parts = value . split ( ',' ) ;
145+ if ( parts . length !== 2 ) {
146+ throw new Error ( 'Invalid data URL format' ) ;
147+ }
148+
149+ const data = parts [ 1 ] ;
150+
151+ // Decode base64 to binary string
152+ const binaryString = atob ( data ) ;
153+ const bytes = new Uint8Array ( binaryString . length ) ;
154+ for ( let i = 0 ; i < binaryString . length ; i ++ ) {
155+ bytes [ i ] = binaryString . charCodeAt ( i ) ;
156+ }
157+
158+ blob = new Blob ( [ bytes ] , { type : originalMimeType } ) ;
159+ } else {
160+ // It's a regular URL, fetch it
161+ const response = await fetch ( value ) ;
162+ if ( ! response . ok ) {
163+ throw new Error ( `Failed to fetch image: ${ response . statusText } ` ) ;
164+ }
165+ blob = await response . blob ( ) ;
166+ originalMimeType = blob . type || 'image/png' ;
167+ }
168+
169+ // Convert image to PNG if it's in an unsupported format for clipboard
170+ const supportedMimeTypes = [ 'image/png' , 'image/jpeg' , 'image/jpg' ] ;
171+ let finalBlob = blob ;
172+ let finalMimeType = originalMimeType ;
173+
174+ if ( ! supportedMimeTypes . includes ( originalMimeType ) ) {
175+ // Convert to PNG using canvas
176+ const canvas = document . createElement ( 'canvas' ) ;
177+ const ctx = canvas . getContext ( '2d' ) ;
178+ if ( ! ctx ) {
179+ throw new Error ( 'Could not get canvas context' ) ;
180+ }
181+
182+ const img = new Image ( ) ;
183+ img . crossOrigin = 'anonymous' ;
184+ const blobUrl = URL . createObjectURL ( blob ) ;
113185
186+ try {
187+ await new Promise < void > ( ( resolve , reject ) => {
188+ img . onload = ( ) => {
189+ canvas . width = img . width ;
190+ canvas . height = img . height ;
191+ ctx . drawImage ( img , 0 , 0 ) ;
192+ canvas . toBlob (
193+ ( pngBlob ) => {
194+ if ( ! pngBlob ) {
195+ reject ( new Error ( 'Failed to convert image to PNG' ) ) ;
196+ } else {
197+ finalBlob = pngBlob ;
198+ finalMimeType = 'image/png' ;
199+ resolve ( ) ;
200+ }
201+ } ,
202+ 'image/png'
203+ ) ;
204+ } ;
205+ img . onerror = ( ) => {
206+ reject ( new Error ( 'Failed to load image' ) ) ;
207+ } ;
208+ img . src = blobUrl ;
209+ } ) ;
210+ } finally {
211+ URL . revokeObjectURL ( blobUrl ) ;
212+ }
213+ }
214+
215+ // Use Clipboard API to copy image blob
216+ const clipboardItem = new ClipboardItem ( {
217+ [ finalMimeType ] : finalBlob
218+ } ) ;
219+
220+ await navigator . clipboard . write ( [ clipboardItem ] ) ;
221+
222+ // Set image copied state for UI feedback
223+ setImageCopied ( true ) ;
224+ setHasCopyError ( false ) ;
225+
226+ if ( showToast ) {
227+ addToast ( {
228+ title : toastText ,
229+ variant : "solid" ,
230+ timeout : copiedTimeout
231+ } ) ;
232+ }
233+
234+ if ( onCopySuccess ) {
235+ onCopySuccess ( ) ;
236+ }
237+ } catch ( error ) {
238+ setHasCopyError ( true ) ;
239+ setImageCopied ( false ) ;
240+
241+ if ( showToast ) {
242+ addToast ( {
243+ title : t ( 'failed-to-copy' ) ,
244+ variant : "solid" ,
245+ timeout : copiedTimeout
246+ } ) ;
247+ }
248+
249+ if ( onCopyError ) {
250+ onCopyError ( error ) ;
251+ }
252+ }
253+ } ) ( ) ;
254+ } else {
255+ // Handle text copying (original behavior)
114256 copy ( value ) ;
115257
116258 if ( showToast ) {
@@ -124,50 +266,38 @@ export const CopyButton = memo<CopyButtonProps>(
124266 if ( onCopySuccess ) {
125267 onCopySuccess ( ) ;
126268 }
127- } catch ( error ) {
128- setHasCopyError ( true ) ;
129-
130- if ( showToast ) {
131- addToast ( {
132- title : t ( 'failed-to-copy' ) ,
133- variant : "solid" ,
134- timeout : copiedTimeout
135- } ) ;
136- }
137-
138- if ( onCopyError ) {
139- onCopyError ( error ) ;
140- }
141269 }
142- } , [ value , copy , showToast , toastText , copiedTimeout , onCopySuccess , onCopyError ] ) ;
270+ } , [ value , copy , showToast , toastText , copiedTimeout , onCopySuccess , onCopyError , isImage , t ] ) ;
271+
272+ const isCopied = copied || imageCopied ;
143273
144274 const icon = hasCopyError ? (
145275 < ErrorIcon
146276 className = "opacity-0 scale-50 text-danger data-[visible=true]:opacity-100 data-[visible=true]:scale-100 transition-transform-opacity"
147277 data-visible = { hasCopyError }
148278 size = { 16 }
149279 />
150- ) : copied ? (
280+ ) : isCopied ? (
151281 < CheckLinearIcon
152282 className = "opacity-0 scale-50 text-success data-[visible=true]:opacity-100 data-[visible=true]:scale-100 transition-transform-opacity"
153- data-visible = { copied }
283+ data-visible = { isCopied }
154284 size = { 16 }
155285 />
156286 ) : (
157287 < CopyLinearIcon
158288 className = "opacity-0 scale-50 data-[visible=true]:opacity-100 data-[visible=true]:scale-100 transition-transform-opacity"
159- data-visible = { ! copied && ! hasCopyError }
289+ data-visible = { ! isCopied && ! hasCopyError }
160290 size = { 16 }
161291 />
162292 ) ;
163293
164294 return (
165295 < PreviewButton
166- className = { className ?? "- bottom-0 left-0.5" }
296+ className = { className ?? "bottom-0 left-0.5" }
167297 icon = { icon }
168298 onPress = { handleCopy }
169- aria-label = { copied ? t ( 'copied' ) : t ( 'copy-to-clipboard' ) }
170- title = { copied ? t ( 'copied' ) : t ( 'copy-to-clipboard' ) }
299+ aria-label = { isCopied ? t ( 'copied' ) : t ( 'copy-to-clipboard' ) }
300+ title = { isCopied ? t ( 'copied' ) : t ( 'copy-to-clipboard' ) }
171301 { ...buttonProps }
172302 />
173303 ) ;
0 commit comments