diff --git a/src/components/ActionButtonsArea.tsx b/src/components/ActionButtonsArea.tsx index fb30512..f9aa93e 100644 --- a/src/components/ActionButtonsArea.tsx +++ b/src/components/ActionButtonsArea.tsx @@ -3,19 +3,23 @@ import { Icon } from "./ui/Icon"; import { SocialIcon } from 'react-social-icons'; import styles from "./ActionButtonsArea.module.css"; +type ActionHandler = () => void | Promise; + interface ActionButtonsAreaProps { actionType: "wallpaper" | "devemon" | "banner"; - onDownloadDesktop?: () => void; - onDownloadMobile?: () => void; - onDownloadSmall?: () => void; - onDownloadCard?: () => void; - onDownloadBadge?: () => void; - onDownloadBanner?: () => void; - onCopyMarkdown?: () => void; - onShareTwitter?: () => void; - onShareBluesky?: () => void; - onShareThreads?: () => void; - onShareInstagram?: () => void; + busyAction: string | null; + onRunAction: (actionId: string, action?: ActionHandler) => void; + onDownloadDesktop?: ActionHandler; + onDownloadMobile?: ActionHandler; + onDownloadSmall?: ActionHandler; + onDownloadCard?: ActionHandler; + onDownloadBadge?: ActionHandler; + onDownloadBanner?: ActionHandler; + onCopyMarkdown?: ActionHandler; + onShareTwitter?: ActionHandler; + onShareBluesky?: ActionHandler; + onShareThreads?: ActionHandler; + onShareInstagram?: ActionHandler; } export default function ActionButtonsArea({ @@ -31,61 +35,91 @@ export default function ActionButtonsArea({ onShareBluesky, onShareThreads, onShareInstagram, + busyAction, + onRunAction, }: ActionButtonsAreaProps) { + const isBusy = busyAction !== null; + const isActionBusy = (actionId: string) => busyAction === actionId; + const runAction = (actionId: string, action?: ActionHandler) => { + onRunAction(actionId, action); + }; + const actionLabel = (actionId: string, label: string) => + isActionBusy(actionId) ? "Generating image..." : label; + const copyMarkdownLabel = isActionBusy("banner-markdown") + ? "Copying..." + : "Copy Markdown"; + const wallpaperDownloadLabel = (actionId: string, label: string) => ( + <> +
+ {isActionBusy(actionId) ? "Generating image..." : label} +
+
+ {isActionBusy(actionId) ? "Please wait" : "Download PNG"} +
+ + ); + const renderWallpaperActions = () => (
runAction("wallpaper-desktop", onDownloadDesktop)} + disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-desktop")} > -
- Desktop (2560x1440) -
-
Download PNG
+ {wallpaperDownloadLabel("wallpaper-desktop", "Desktop (2560x1440)")}
runAction("wallpaper-mobile", onDownloadMobile)} + disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-mobile")} > -
- Mobile (1179x2556) -
-
Download PNG
+ {wallpaperDownloadLabel("wallpaper-mobile", "Mobile (1179x2556)")}
runAction("wallpaper-small", onDownloadSmall)} + disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-small")} > -
Badge (320x240)
-
Download PNG
+ {wallpaperDownloadLabel("wallpaper-small", "Badge (320x240)")}
runAction("wallpaper-twitter", onShareTwitter)} + disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-twitter")} icon={} > - Twitter/X + {actionLabel("wallpaper-twitter", "Twitter/X")} runAction("wallpaper-bluesky", onShareBluesky)} + disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-bluesky")} icon={} > - Bluesky + {actionLabel("wallpaper-bluesky", "Bluesky")} runAction("wallpaper-threads", onShareThreads)} + disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-threads")} icon={} > - Threads + {actionLabel("wallpaper-threads", "Threads")} runAction("wallpaper-instagram", onShareInstagram)} + disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-instagram")} icon={} > - Instagram + {actionLabel("wallpaper-instagram", "Instagram")}
@@ -96,7 +130,9 @@ export default function ActionButtonsArea({
runAction("devemon-card", onDownloadCard)} + disabled={isBusy} + ariaBusy={isActionBusy("devemon-card")} icon={ } > - Download Card + {actionLabel("devemon-card", "Download Card")} runAction("devemon-badge", onDownloadBadge)} + disabled={isBusy} + ariaBusy={isActionBusy("devemon-badge")} icon={ } > - Download Badge + {actionLabel("devemon-badge", "Download Badge")}
runAction("devemon-twitter", onShareTwitter)} + disabled={isBusy} + ariaBusy={isActionBusy("devemon-twitter")} icon={} > - Twitter/X + {actionLabel("devemon-twitter", "Twitter/X")} runAction("devemon-bluesky", onShareBluesky)} + disabled={isBusy} + ariaBusy={isActionBusy("devemon-bluesky")} icon={} > - Bluesky + {actionLabel("devemon-bluesky", "Bluesky")} runAction("devemon-threads", onShareThreads)} + disabled={isBusy} + ariaBusy={isActionBusy("devemon-threads")} icon={} > - Threads + {actionLabel("devemon-threads", "Threads")} runAction("devemon-instagram", onShareInstagram)} + disabled={isBusy} + ariaBusy={isActionBusy("devemon-instagram")} icon={} > - Instagram + {actionLabel("devemon-instagram", "Instagram")}
@@ -155,7 +201,9 @@ export default function ActionButtonsArea({
runAction("banner-download", onDownloadBanner)} + disabled={isBusy} + ariaBusy={isActionBusy("banner-download")} icon={ } > - Download + {actionLabel("banner-download", "Download")} - 📋}> - Copy Markdown + runAction("banner-markdown", onCopyMarkdown)} + disabled={isBusy} + ariaBusy={isActionBusy("banner-markdown")} + icon={📋} + > + {copyMarkdownLabel}
runAction("banner-twitter", onShareTwitter)} + disabled={isBusy} + ariaBusy={isActionBusy("banner-twitter")} icon={} > - Twitter/X + {actionLabel("banner-twitter", "Twitter/X")} runAction("banner-bluesky", onShareBluesky)} + disabled={isBusy} + ariaBusy={isActionBusy("banner-bluesky")} icon={} > - Bluesky + {actionLabel("banner-bluesky", "Bluesky")} runAction("banner-threads", onShareThreads)} + disabled={isBusy} + ariaBusy={isActionBusy("banner-threads")} icon={} > - Threads + {actionLabel("banner-threads", "Threads")} runAction("banner-instagram", onShareInstagram)} + disabled={isBusy} + ariaBusy={isActionBusy("banner-instagram")} icon={} > - Instagram + {actionLabel("banner-instagram", "Instagram")}
diff --git a/src/components/DevemonCard.tsx b/src/components/DevemonCard.tsx index 2b683b6..5ab7465 100644 --- a/src/components/DevemonCard.tsx +++ b/src/components/DevemonCard.tsx @@ -18,6 +18,7 @@ import { Checkbox, PrimerSelect } from "./ui/FormControls"; import { Button } from "./ui/Button"; import styles from "./DevemonCard.module.css"; import sharedStyles from "./shared.module.css"; +import { copyPngBlobToClipboard } from "../utils/imageExport"; import { downloadElementAsPng, elementToPngBlob } from "../utils/domExport"; interface DevemonCardProps { @@ -202,170 +203,138 @@ const DevemonCard = forwardRef( } }; - /** - * Share card to Twitter/X - */ - const handleTwitterShare = async () => { + const copyCardAndShare = async ({ + successMessage, + copyFailureMessage, + generationFailureMessage, + shareUrl, + windowName, + windowFeatures, + afterShare, + }: { + successMessage: string; + copyFailureMessage: string; + generationFailureMessage: string; + shareUrl?: string; + windowName: string; + windowFeatures: string; + afterShare?: () => void; + }) => { if (!cardRef.current) return; try { const blob = await elementToPngBlob(cardRef.current); + if (!blob) { + throw new Error("Failed to generate card image"); + } - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard! You can now paste it in your tweet." - ); + try { + await copyPngBlobToClipboard(blob); + alert(successMessage); - const tweetText = - "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - window.open( - twitterUrl, - "twitter-share-dialog", - "width=626,height=436" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." - ); + if (shareUrl) { + window.open(shareUrl, windowName, windowFeatures); } + afterShare?.(); + } catch (error) { + console.error("Error copying to clipboard:", error); + alert(copyFailureMessage); } } catch (error) { - console.error("Error sharing to Twitter:", error); - alert("Failed to generate card image. Please try again."); + console.error(`Error preparing card for ${windowName}:`, error); + alert(generationFailureMessage); } }; + /** + * Share card to Twitter/X + */ + const handleTwitterShare = async () => { + const tweetText = + "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + tweetText + )}`; + + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard! You can now paste it in your tweet.", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + shareUrl: twitterUrl, + windowName: "twitter-share-dialog", + windowFeatures: "width=626,height=436", + }); + }; + /** * Share card to Bluesky */ const handleBlueskyShare = async () => { - if (!cardRef.current) return; - - try { - const blob = await elementToPngBlob(cardRef.current); - - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard! You can now paste it in your Bluesky post." - ); - - const postText = - "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; - const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( - postText - )}`; - window.open( - blueskyUrl, - "bluesky-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." - ); - } - } - } catch (error) { - console.error("Error sharing to Bluesky:", error); - alert("Failed to generate card image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; + const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( + postText + )}`; + + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard! You can now paste it in your Bluesky post.", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + shareUrl: blueskyUrl, + windowName: "bluesky-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share card to Threads */ const handleThreadsShare = async () => { - if (!cardRef.current) return; - - try { - const blob = await elementToPngBlob(cardRef.current); - - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard! You can now paste it in your Threads post." - ); - - const postText = - "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; - const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( - postText - )}`; - window.open( - threadsUrl, - "threads-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." - ); - } - } - } catch (error) { - console.error("Error sharing to Threads:", error); - alert("Failed to generate card image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; + const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( + postText + )}`; + + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard! You can now paste it in your Threads post.", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + shareUrl: threadsUrl, + windowName: "threads-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share card to Instagram */ const handleInstagramShare = async () => { - if (!cardRef.current) return; - - try { - const blob = await elementToPngBlob(cardRef.current); - - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!" - ); - - window.location.href = "instagram://library"; - setTimeout(() => { - window.open( - "https://www.instagram.com/", - "instagram-share", - "width=626,height=600" - ); - }, 1500); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + windowName: "instagram-share", + windowFeatures: "width=626,height=600", + afterShare: () => { + window.location.href = "instagram://library"; + setTimeout(() => { + window.open( + "https://www.instagram.com/", + "instagram-share", + "width=626,height=600" ); - } - } - } catch (error) { - console.error("Error sharing to Instagram:", error); - alert("Failed to generate card image. Please try again."); - } + }, 1500); + }, + }); }; if (loading) { diff --git a/src/components/GitHubWallpaperApp.tsx b/src/components/GitHubWallpaperApp.tsx index d2f119d..5eb5b81 100644 --- a/src/components/GitHubWallpaperApp.tsx +++ b/src/components/GitHubWallpaperApp.tsx @@ -16,6 +16,7 @@ // @ts-nocheck import { useState, useRef } from "preact/hooks"; +import { flushSync } from "preact/compat"; import type { JSX } from "preact"; import WallpaperGenerator, { type WallpaperGeneratorRef, @@ -79,6 +80,7 @@ export default function GitHubWallpaperApp() { const [error, setError] = useState(""); const [selectedTab, setSelectedTab] = useState(0); const [hasSubmitted, setHasSubmitted] = useState(false); + const [busyAction, setBusyAction] = useState(null); // Wallpaper form controls const [wallpaperTheme, setWallpaperTheme] = useState< @@ -114,6 +116,30 @@ export default function GitHubWallpaperApp() { { label: "README Banner", shortLabel: "Banner" }, ]; + const runAction = async (actionId: string, action?: () => void | Promise) => { + if (!action || busyAction) return; + + const minimumBusyMs = actionId === "banner-markdown" ? 0 : 500; + const startedAt = performance.now(); + flushSync(() => { + setBusyAction(actionId); + }); + try { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + await action(); + } finally { + const remainingBusyMs = minimumBusyMs - (performance.now() - startedAt); + if (remainingBusyMs > 0) { + await new Promise((resolve) => { + setTimeout(resolve, remainingBusyMs); + }); + } + setBusyAction(null); + } + }; + // Support left/right arrow key navigation (adapted from Universe Tickets) const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement; @@ -593,6 +619,8 @@ export default function GitHubWallpaperApp() { {selectedTab === 0 && ( wallpaperGeneratorRef.current?.downloadWallpaper("desktop") } @@ -620,6 +648,8 @@ export default function GitHubWallpaperApp() { {selectedTab === 1 && ( devemonCardRef.current?.downloadCard("card") } @@ -644,6 +674,8 @@ export default function GitHubWallpaperApp() { {selectedTab === 2 && ( readmeBannerRef.current?.downloadBanner()} onCopyMarkdown={() => readmeBannerRef.current?.copyMarkdown()} onShareTwitter={() => diff --git a/src/components/ReadmeBanner.tsx b/src/components/ReadmeBanner.tsx index 458d70f..0a39f4a 100644 --- a/src/components/ReadmeBanner.tsx +++ b/src/components/ReadmeBanner.tsx @@ -21,6 +21,7 @@ import { Icon } from "./ui/Icon"; import { PrimaryButton, SecondaryButton } from "./ui/Button"; import styles from "./ReadmeBanner.module.css"; import sharedStyles from "./shared.module.css"; +import { copyPngBlobToClipboard } from "../utils/imageExport"; import { downloadElementAsPng, elementToPngBlob } from "../utils/domExport"; /** README banners are wide, so 2x keeps the exported file size reasonable. */ @@ -528,178 +529,140 @@ const ReadmeBanner = forwardRef( }); }; - /** - * Share banner to Twitter/X - */ - const handleTwitterShare = async () => { + const copyBannerAndShare = async ({ + successMessage, + copyFailureMessage, + generationFailureMessage, + shareUrl, + windowName, + windowFeatures, + afterShare, + }: { + successMessage: string; + copyFailureMessage: string; + generationFailureMessage: string; + shareUrl?: string; + windowName: string; + windowFeatures: string; + afterShare?: () => void; + }) => { if (!standardBannerRef.current) return; try { const blob = await elementToPngBlob(standardBannerRef.current, { scale: BANNER_EXPORT_SCALE, }); + if (!blob) { + throw new Error("Failed to generate banner image"); + } - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard! You can now paste it in your tweet." - ); + try { + await copyPngBlobToClipboard(blob); + alert(successMessage); - const tweetText = - "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - window.open( - twitterUrl, - "twitter-share-dialog", - "width=626,height=436" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." - ); + if (shareUrl) { + window.open(shareUrl, windowName, windowFeatures); } + afterShare?.(); + } catch (error) { + console.error("Error copying to clipboard:", error); + alert(copyFailureMessage); } } catch (error) { - console.error("Error sharing to Twitter:", error); - alert("Failed to generate banner image. Please try again."); + console.error(`Error preparing banner for ${windowName}:`, error); + alert(generationFailureMessage); } }; + /** + * Share banner to Twitter/X + */ + const handleTwitterShare = async () => { + const tweetText = + "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + tweetText + )}`; + + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard! You can now paste it in your tweet.", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + shareUrl: twitterUrl, + windowName: "twitter-share-dialog", + windowFeatures: "width=626,height=436", + }); + }; + /** * Share banner to Bluesky */ const handleBlueskyShare = async () => { - if (!standardBannerRef.current) return; - - try { - const blob = await elementToPngBlob(standardBannerRef.current, { - scale: BANNER_EXPORT_SCALE, - }); - - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard! You can now paste it in your Bluesky post." - ); - - const postText = - "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; - const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( - postText - )}`; - window.open( - blueskyUrl, - "bluesky-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." - ); - } - } - } catch (error) { - console.error("Error sharing to Bluesky:", error); - alert("Failed to generate banner image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; + const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( + postText + )}`; + + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard! You can now paste it in your Bluesky post.", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + shareUrl: blueskyUrl, + windowName: "bluesky-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share banner to Threads */ const handleThreadsShare = async () => { - if (!standardBannerRef.current) return; - - try { - const blob = await elementToPngBlob(standardBannerRef.current, { - scale: BANNER_EXPORT_SCALE, - }); - - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard! You can now paste it in your Threads post." - ); - - const postText = - "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; - const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( - postText - )}`; - window.open( - threadsUrl, - "threads-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." - ); - } - } - } catch (error) { - console.error("Error sharing to Threads:", error); - alert("Failed to generate banner image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; + const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( + postText + )}`; + + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard! You can now paste it in your Threads post.", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + shareUrl: threadsUrl, + windowName: "threads-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share banner to Instagram */ const handleInstagramShare = async () => { - if (!standardBannerRef.current) return; - - try { - const blob = await elementToPngBlob(standardBannerRef.current, { - scale: BANNER_EXPORT_SCALE, - }); - - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!" - ); - - window.location.href = "instagram://library"; - setTimeout(() => { - window.open( - "https://www.instagram.com/", - "instagram-share", - "width=626,height=600" - ); - }, 1500); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + windowName: "instagram-share", + windowFeatures: "width=626,height=600", + afterShare: () => { + window.location.href = "instagram://library"; + setTimeout(() => { + window.open( + "https://www.instagram.com/", + "instagram-share", + "width=626,height=600" ); - } - } - } catch (error) { - console.error("Error sharing to Instagram:", error); - alert("Failed to generate banner image. Please try again."); - } + }, 1500); + }, + }); }; // Expose functions via ref diff --git a/src/components/WallpaperGenerator.tsx b/src/components/WallpaperGenerator.tsx index 5f01aa0..d28e35c 100644 --- a/src/components/WallpaperGenerator.tsx +++ b/src/components/WallpaperGenerator.tsx @@ -13,6 +13,12 @@ import { forwardRef } from "preact/compat"; import type { GitHubUser } from "./GitHubWallpaperApp"; import styles from "./WallpaperGenerator.module.css"; import sharedStyles from "./shared.module.css"; +import { + copyPngBlobToClipboard, + downloadBlob, + svgToPngBlob, + yieldToBrowser, +} from "../utils/imageExport"; interface WallpaperGeneratorProps { user: GitHubUser; @@ -865,7 +871,7 @@ const WallpaperGenerator = forwardRef< * Convert SVG to PNG blob (for clipboard or download) * Reusable function for both download and share operations */ - const copyToClipboard = async ( + const createWallpaperPngBlob = async ( sizeKey: SizeKey = "desktop" ): Promise => { const size = SIZES[sizeKey]; @@ -876,63 +882,9 @@ const WallpaperGenerator = forwardRef< throw new Error("Avatar not loaded yet"); } + await yieldToBrowser(); const svgString = generateSVG(size.width, size.height, true); // true = static version - - // Create canvas for PNG conversion - const canvas = document.createElement("canvas"); - canvas.width = size.width; - canvas.height = size.height; - const ctx = canvas.getContext("2d", { willReadFrequently: false }); - - if (!ctx) { - throw new Error("Failed to get canvas context"); - } - - // Encode SVG string properly for data URL - const encodedSvg = encodeURIComponent(svgString) - .replace(/'/g, "%27") - .replace(/"/g, "%22"); - - const dataUrl = `data:image/svg+xml,${encodedSvg}`; - - // Create image and wait for it to load - const img = new Image(); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Image load timeout after 10 seconds")); - }, 10000); // 10 second timeout - - img.onload = () => { - clearTimeout(timeout); - resolve(); - }; - - img.onerror = (e) => { - clearTimeout(timeout); - console.error("Failed to load SVG image:", e); - reject(new Error("Failed to load SVG image")); - }; - - img.src = dataUrl; - }); - - // Draw the SVG image onto canvas - ctx.drawImage(img, 0, 0); - - // Convert canvas to PNG blob - const blob = await new Promise((resolve, reject) => { - canvas.toBlob( - (b) => { - if (b) resolve(b); - else reject(new Error("Failed to create blob from canvas")); - }, - "image/png", - 1.0 - ); - }); - - return blob; + return svgToPngBlob(svgString, size.width, size.height); }; /** @@ -943,20 +895,8 @@ const WallpaperGenerator = forwardRef< setDownloadingSize(sizeKey); try { - const blob = await copyToClipboard(sizeKey); - - const pngUrl = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = pngUrl; - link.download = `github-wallpaper-${user.login}-${sizeKey}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Clean up - setTimeout(() => { - URL.revokeObjectURL(pngUrl); - }, 100); + const blob = await createWallpaperPngBlob(sizeKey); + downloadBlob(blob, `github-wallpaper-${user.login}-${sizeKey}.png`); console.log(`Successfully downloaded ${sizeKey} wallpaper`); } catch (error) { @@ -970,13 +910,21 @@ const WallpaperGenerator = forwardRef< } }; - /** - * Share wallpaper to Twitter/X - * Copies desktop wallpaper to clipboard and opens Twitter with pre-filled text - */ - const handleTwitterShare = async () => { - // Check if avatar is loaded before proceeding - // Use ref to get current value (not closure value) + const copyDesktopWallpaperAndShare = async ({ + successMessage, + failureMessage, + shareUrl, + windowName, + windowFeatures, + afterShare, + }: { + successMessage: string; + failureMessage: string; + shareUrl?: string; + windowName: string; + windowFeatures: string; + afterShare?: () => void; + }) => { const currentAvatarBase64 = avatarBase64Ref.current; if (!currentAvatarBase64) { alert( @@ -986,46 +934,40 @@ const WallpaperGenerator = forwardRef< } try { - // Copy wallpaper to clipboard first - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - // Show success message - alert( - "✅ Wallpaper copied to clipboard! You can now paste it in your tweet." - ); - - // Open Twitter with pre-filled text - const tweetText = SHARE_POST_TEXT; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - - window.open( - twitterUrl, - "twitter-share-dialog", - "width=626,height=436,toolbar=0,menubar=0,location=0,status=0" - ); + const blob = await createWallpaperPngBlob("desktop"); + await copyPngBlobToClipboard(blob); + alert(successMessage); } catch (error) { - console.error("Error sharing to Twitter:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and attach to your tweet." - ); - - // Still open Twitter even if clipboard fails - const tweetText = SHARE_POST_TEXT; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - window.open(twitterUrl, "twitter-share-dialog", "width=626,height=436"); + console.error(`Error preparing wallpaper for ${windowName}:`, error); + alert(failureMessage); + } finally { + if (shareUrl) { + window.open(shareUrl, windowName, windowFeatures); + } + afterShare?.(); } }; + /** + * Share wallpaper to Twitter/X + * Copies desktop wallpaper to clipboard and opens Twitter with pre-filled text + */ + const handleTwitterShare = async () => { + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + SHARE_POST_TEXT + )}`; + + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard! You can now paste it in your tweet.", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and attach to your tweet.", + shareUrl: twitterUrl, + windowName: "twitter-share-dialog", + windowFeatures: "width=626,height=436,toolbar=0,menubar=0,location=0,status=0", + }); + }; + /** * Share wallpaper to Bluesky * Copies desktop wallpaper to clipboard and opens Bluesky with pre-filled text @@ -1044,32 +986,15 @@ const WallpaperGenerator = forwardRef< postText )}`; - try { - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - alert( - "✅ Wallpaper copied to clipboard! You can now paste it in your Bluesky post." - ); - - window.open( - blueskyUrl, - "bluesky-share-dialog", - "width=626,height=600,toolbar=0,menubar=0,location=0,status=0" - ); - } catch (error) { - console.error("Error sharing to Bluesky:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post." - ); - - window.open(blueskyUrl, "bluesky-share-dialog", "width=626,height=600"); - } + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard! You can now paste it in your Bluesky post.", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post.", + shareUrl: blueskyUrl, + windowName: "bluesky-share-dialog", + windowFeatures: "width=626,height=600,toolbar=0,menubar=0,location=0,status=0", + }); }; /** @@ -1090,32 +1015,15 @@ const WallpaperGenerator = forwardRef< postText )}`; - try { - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - alert( - "✅ Wallpaper copied to clipboard! You can now paste it in your Threads post." - ); - - window.open( - threadsUrl, - "threads-share-dialog", - "width=626,height=600,toolbar=0,menubar=0,location=0,status=0" - ); - } catch (error) { - console.error("Error sharing to Threads:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post." - ); - - window.open(threadsUrl, "threads-share-dialog", "width=626,height=600"); - } + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard! You can now paste it in your Threads post.", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post.", + shareUrl: threadsUrl, + windowName: "threads-share-dialog", + windowFeatures: "width=626,height=600,toolbar=0,menubar=0,location=0,status=0", + }); }; /** @@ -1123,31 +1031,9 @@ const WallpaperGenerator = forwardRef< * Copies desktop wallpaper to clipboard and shows instructions for Instagram app */ const handleInstagramShare = async () => { - const currentAvatarBase64 = avatarBase64Ref.current; - if (!currentAvatarBase64) { - alert( - "⏳ Please wait - your avatar is still loading. Try again in a moment!" - ); - return; - } - - try { - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - alert( - "✅ Wallpaper copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!" - ); - - // Try to open Instagram app (works on mobile devices) + const openInstagram = () => { window.location.href = "instagram://library"; - // Fallback to web after a delay (if app doesn't open) setTimeout(() => { window.open( "https://www.instagram.com/", @@ -1155,22 +1041,17 @@ const WallpaperGenerator = forwardRef< "width=626,height=600" ); }, 1500); - } catch (error) { - console.error("Error sharing to Instagram:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and upload to Instagram." - ); + }; - // Still try to open Instagram - window.location.href = "instagram://library"; - setTimeout(() => { - window.open( - "https://www.instagram.com/", - "instagram-share", - "width=626,height=600" - ); - }, 1500); - } + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and upload to Instagram.", + windowName: "instagram-share", + windowFeatures: "width=626,height=600", + afterShare: openInstagram, + }); }; // Generate responsive preview SVG based on screen size diff --git a/src/components/ui/Button.module.css b/src/components/ui/Button.module.css index fbf12ca..0dd69a9 100644 --- a/src/components/ui/Button.module.css +++ b/src/components/ui/Button.module.css @@ -35,6 +35,8 @@ .button:disabled { cursor: not-allowed; + opacity: 0.65; + pointer-events: none; } /* Button text styling */ diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 59aa945..ca629d7 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -24,7 +24,7 @@ export interface ButtonProps { size?: "medium" | "large"; /** Click handler */ - onClick?: () => void; + onClick?: () => void | Promise; /** Button type */ type?: "button" | "submit" | "reset"; @@ -32,6 +32,9 @@ export interface ButtonProps { /** Disabled state */ disabled?: boolean; + /** Busy state for assistive technology */ + ariaBusy?: boolean; + /** Full width button */ fullWidth?: boolean; @@ -49,6 +52,7 @@ export function Button({ onClick, type = "button", disabled = false, + ariaBusy = false, fullWidth = false, className = "", icon, @@ -70,6 +74,7 @@ export function Button({ className={buttonClasses} onClick={onClick} disabled={disabled} + aria-busy={ariaBusy || undefined} > {/* Button text with GitHub Universe styling */} {children} diff --git a/src/utils/domExport.ts b/src/utils/domExport.ts index ff7aac9..d6b5fa0 100644 --- a/src/utils/domExport.ts +++ b/src/utils/domExport.ts @@ -17,6 +17,8 @@ * construction rather than by per-element pixel nudging. */ +import { canvasToPngBlob, downloadBlob, yieldToBrowser } from "./imageExport"; + /** Default scale so downloads stay crisp on hi-dpi displays. */ export const DEFAULT_EXPORT_SCALE = 3; @@ -32,6 +34,8 @@ export async function elementToCanvas( element: HTMLElement, { scale = DEFAULT_EXPORT_SCALE }: ExportOptions = {}, ): Promise { + await yieldToBrowser(); + const { domToCanvas } = await import("modern-screenshot"); return domToCanvas(element, { @@ -46,12 +50,18 @@ export async function elementToCanvas( export async function elementToPngBlob( element: HTMLElement, options: ExportOptions = {}, -): Promise { - const canvas = await elementToCanvas(element, options); +): Promise { + let canvas: HTMLCanvasElement | undefined; - return new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), "image/png"); - }); + try { + canvas = await elementToCanvas(element, options); + return await canvasToPngBlob(canvas); + } finally { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + } } /** @@ -63,12 +73,5 @@ export async function downloadElementAsPng( options: ExportOptions = {}, ): Promise { const blob = await elementToPngBlob(element, options); - if (!blob) return; - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - link.click(); - URL.revokeObjectURL(url); + downloadBlob(blob, fileName); } diff --git a/src/utils/imageExport.ts b/src/utils/imageExport.ts new file mode 100644 index 0000000..c0381a5 --- /dev/null +++ b/src/utils/imageExport.ts @@ -0,0 +1,126 @@ +const nextFrame = () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + +export const yieldToBrowser = async () => { + await nextFrame(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}; + +export const canvasToPngBlob = async (canvas: HTMLCanvasElement) => { + await yieldToBrowser(); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((value) => { + if (value) { + resolve(value); + } else { + reject(new Error("Failed to create PNG blob from canvas")); + } + }, "image/png"); + }); + + await yieldToBrowser(); + return blob; +}; + +const loadSvgBlobIntoImage = async (svgBlob: Blob) => { + if ("createImageBitmap" in window) { + try { + return await window.createImageBitmap(svgBlob); + } catch (error) { + console.warn( + "createImageBitmap failed for SVG export; using Image fallback.", + error + ); + } + } + + const objectUrl = URL.createObjectURL(svgBlob); + const image = new Image(); + + try { + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + reject(new Error("Image load timeout after 10 seconds")); + }, 10000); + + image.onload = () => { + window.clearTimeout(timeout); + resolve(); + }; + + image.onerror = () => { + window.clearTimeout(timeout); + reject(new Error("Failed to load SVG image")); + }; + + image.src = objectUrl; + }); + + return image; + } finally { + URL.revokeObjectURL(objectUrl); + } +}; + +export const svgToPngBlob = async ( + svgString: string, + width: number, + height: number +) => { + await yieldToBrowser(); + + const svgBlob = new Blob([svgString], { + type: "image/svg+xml;charset=utf-8", + }); + let image: ImageBitmap | HTMLImageElement | undefined; + const canvas = document.createElement("canvas"); + + try { + image = await loadSvgBlobIntoImage(svgBlob); + + await yieldToBrowser(); + + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext("2d", { willReadFrequently: false }); + if (!context) { + throw new Error("Failed to get canvas context"); + } + + context.drawImage(image, 0, 0, width, height); + return await canvasToPngBlob(canvas); + } finally { + if (image && "close" in image && typeof image.close === "function") { + image.close(); + } + + canvas.width = 0; + canvas.height = 0; + } +}; + +export const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.setTimeout(() => URL.revokeObjectURL(url), 100); +}; + +export const copyPngBlobToClipboard = async (blob: Blob) => { + await navigator.clipboard.write([ + new ClipboardItem({ + "image/png": blob, + }), + ]); +}; diff --git a/tests/export-responsiveness.spec.ts b/tests/export-responsiveness.spec.ts new file mode 100644 index 0000000..27681d0 --- /dev/null +++ b/tests/export-responsiveness.spec.ts @@ -0,0 +1,230 @@ +import { expect, test, type Page } from "@playwright/test"; + +const avatarPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", + "base64" +); +const interactionTimeoutMs = 750; + +async function mockGitHubProfile(page: Page) { + await page.route("**/users/octocat", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + login: "octocat", + avatar_url: "http://localhost:4321/avatar.png", + name: "The Octocat", + followers: 1234, + public_repos: 42, + bio: "GitHub mascot", + created_at: "2011-01-25T18:44:36Z", + company: "@github", + location: "San Francisco", + blog: "https://github.com", + }), + }); + }); + + await page.route("**/octocat.contribs", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + total_contributions: 1234, + weeks: Array.from({ length: 53 }, () => ({ + contribution_days: Array.from({ length: 7 }, () => ({ count: 2 })), + })), + }), + }); + }); + + await page.route( + "**/users/octocat/repos**", + async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify([ + { + stargazers_count: 10, + forks_count: 5, + language: "TypeScript", + }, + ]), + }); + } + ); + + await page.route("**/avatar.png", async (route) => { + await route.fulfill({ + contentType: "image/png", + body: avatarPng, + }); + }); +} + +async function loadGeneratedProfile(page: Page) { + await page.addInitScript(() => { + window.alert = (message) => { + (window as typeof window & { __octocanvasAlerts: string[] }) + .__octocanvasAlerts ??= []; + (window as typeof window & { __octocanvasAlerts: string[] }) + .__octocanvasAlerts.push(String(message)); + }; + window.open = () => null; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + write: () => + new Promise((resolve) => { + window.setTimeout(resolve, 300); + }), + writeText: () => Promise.resolve(), + }, + }); + + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks = 0; + window.setInterval(() => { + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks += 1; + }, 50); + + const originalToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = function delayedToBlob( + callback, + type, + quality + ) { + return originalToBlob.call( + this, + (blob) => window.setTimeout(() => callback(blob), 1000), + type, + quality + ); + }; + }); + + await mockGitHubProfile(page); + await page.goto("/"); + const usernameInput = page.getByLabel("GitHub Username"); + await usernameInput.click(); + await page.keyboard.type("octocat"); + await expect(usernameInput).toHaveValue("octocat"); + await page.keyboard.press("Tab"); + await page.getByRole("button", { name: "Generate" }).click(); + await expect(page.getByText("Your Wallpaper")).toBeVisible(); +} + +async function showDevemonCard(page: Page) { + await page.getByRole("tab", { name: "Devémon Card" }).click(); + await expect(page.getByText("Your Devémon Card")).toBeVisible(); +} + +async function expectNoGenerationFailureAlert(page: Page) { + const alerts = await page.evaluate( + () => + (window as typeof window & { __octocanvasAlerts?: string[] }) + .__octocanvasAlerts ?? [] + ); + + expect(alerts).not.toContain("Failed to generate card image. Please try again."); +} + +async function expectResponsiveInteraction( + page: Page, + action: () => Promise, + description: string +) { + const startingTicks = await page.evaluate( + () => + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks + ); + const startedAt = Date.now(); + + await action(); + + expect( + Date.now() - startedAt, + `${description} should not wait for image generation to finish` + ).toBeLessThan(interactionTimeoutMs); + + await page.waitForFunction( + (ticks) => + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks > ticks, + startingTicks, + { timeout: interactionTimeoutMs } + ); +} + +test("wallpaper sharing keeps the rest of the page interactive", async ({ + page, +}) => { + await loadGeneratedProfile(page); + + await expectResponsiveInteraction( + page, + () => page.getByRole("button", { name: "Twitter/X" }).click(), + "Wallpaper share click" + ); + const devemonTab = page.getByRole("tab", { name: "Devémon Card" }); + await expectResponsiveInteraction( + page, + () => devemonTab.click(), + "Tab switch during wallpaper share" + ); + + await expect(devemonTab).toHaveAttribute("aria-selected", "true"); +}); + +test("wallpaper downloads keep the rest of the page interactive", async ({ + page, +}) => { + await loadGeneratedProfile(page); + + await expectResponsiveInteraction( + page, + () => page.getByRole("button", { name: /Desktop/ }).click(), + "Wallpaper download click" + ); + const devemonTab = page.getByRole("tab", { name: "Devémon Card" }); + await expectResponsiveInteraction( + page, + () => devemonTab.click(), + "Tab switch during wallpaper download" + ); + + await expect(devemonTab).toHaveAttribute("aria-selected", "true"); +}); + +test("devemon card sharing generates an image without the failure alert", async ({ + page, +}) => { + await loadGeneratedProfile(page); + await showDevemonCard(page); + + await page.getByRole("button", { name: "Twitter/X" }).click(); + + await expectNoGenerationFailureAlert(page); +}); + +test("devemon card sharing keeps the rest of the page interactive", async ({ + page, +}) => { + await loadGeneratedProfile(page); + await showDevemonCard(page); + + await expectResponsiveInteraction( + page, + () => page.getByRole("button", { name: "Twitter/X" }).click(), + "Devémon share click" + ); + const bannerTab = page.getByRole("tab", { name: "README Banner" }); + await expectResponsiveInteraction( + page, + () => bannerTab.click(), + "Tab switch during Devémon share" + ); + + await expect(bannerTab).toHaveAttribute("aria-selected", "true"); +});