Skip to content

Commit f5eefeb

Browse files
committed
feat: copy button can copy images
1 parent 4ec4c36 commit f5eefeb

File tree

2 files changed

+179
-33
lines changed

2 files changed

+179
-33
lines changed

client/src/components/copy-button.tsx

Lines changed: 157 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

8388
export 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(/^data:([^;]+)/);
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
);

client/src/pages/link.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,17 @@ export default function LinkPage() {
174174
</div>
175175
{data.purchaseScreenshot && (
176176
<div>
177-
<p className="font-medium text-gray-600 dark:text-gray-400 mb-2">
178-
{t("purchase-screenshot")}
179-
</p>
177+
<div className="flex items-center justify-between mb-2">
178+
<p className="font-medium text-gray-600 dark:text-gray-400">
179+
{t("purchase-screenshot")}
180+
</p>
181+
<CopyButton
182+
value={data.purchaseScreenshot}
183+
isImage={true}
184+
showToast={true}
185+
toastText={t("copied-to-clipboard")}
186+
/>
187+
</div>
180188
<img
181189
src={data.purchaseScreenshot}
182190
alt="Purchase receipt"
@@ -227,9 +235,17 @@ export default function LinkPage() {
227235
</div>
228236
{data.publicationScreenshot && (
229237
<div>
230-
<p className="font-medium text-gray-600 dark:text-gray-400 mb-2">
231-
{t("publication-screenshot")}
232-
</p>
238+
<div className="flex items-center justify-between mb-2">
239+
<p className="font-medium text-gray-600 dark:text-gray-400">
240+
{t("publication-screenshot")}
241+
</p>
242+
<CopyButton
243+
value={data.publicationScreenshot}
244+
isImage={true}
245+
showToast={true}
246+
toastText={t("copied-to-clipboard")}
247+
/>
248+
</div>
233249
<img
234250
src={data.publicationScreenshot}
235251
alt="Publication proof"

0 commit comments

Comments
 (0)