diff --git a/apps/frontend/e2e/stats-rank.spec.ts b/apps/frontend/e2e/stats-rank.spec.ts index b1180e2f74040..6df3f58e25e66 100644 --- a/apps/frontend/e2e/stats-rank.spec.ts +++ b/apps/frontend/e2e/stats-rank.spec.ts @@ -12,7 +12,7 @@ test("selecting 'None' progress style hides the rank circle", async ({ page.locator("main").getByRole("heading", { level: 1 }), ).toContainText("Modify Card Parameters"); - const preview = page.locator("#svgWrapper"); + const preview = page.locator("#svg-wrapper"); const rankCircle = preview.locator('[data-testid="rank-circle"]'); // Default "Rank" progress style renders the rank circle. diff --git a/apps/frontend/package.json b/apps/frontend/package.json index da142482783e4..5c5d8030f034f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -31,7 +31,6 @@ "react-spinners": "^0.17.0", "react-toastify": "^11.1.0", "redux": "^5.0.1", - "save-svg-as-png": "^1.4.17", "uuid": "^14.0.1" }, "devDependencies": { diff --git a/apps/frontend/src/modules.d.ts b/apps/frontend/src/modules.d.ts deleted file mode 100644 index bb96604b64938..0000000000000 --- a/apps/frontend/src/modules.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This package doesn't have a @types counter part -declare module "save-svg-as-png" { - const saveSvgAsPng: ( - element: Element, - filename: string, - options: { - scale: number; - encoderOptions: number; - }, - ) => void; - export { saveSvgAsPng }; -} diff --git a/apps/frontend/src/wizard/Home/stages/Display.tsx b/apps/frontend/src/wizard/Home/stages/Display.tsx index 89ea760175991..6c80b74e83f08 100644 --- a/apps/frontend/src/wizard/Home/stages/Display.tsx +++ b/apps/frontend/src/wizard/Home/stages/Display.tsx @@ -1,14 +1,27 @@ +import { useRef } from "react"; import type { JSX } from "react"; import { toast } from "react-toastify"; -import { saveSvgAsPng } from "save-svg-as-png"; +import type { ToastOptions } from "react-toastify"; import { HOST } from "../../../constants"; import { CardImage } from "../../components/Card/CardImage"; +import { downloadSvgAsPng } from "../../components/Card/downloadSvgAsPng"; import { getCardThemeBackdrop } from "../../components/Card/themeBackdrop"; import { Button } from "../../components/Generic/Button"; import type { CardUrlBuilder } from "../../models/CardUrl"; import { useIsDarkTheme } from "../../useIsDarkTheme"; +const TOAST_OPTIONS: ToastOptions = { + position: "bottom-right", + autoClose: 1500, + hideProgressBar: true, + closeOnClick: false, + pauseOnHover: true, + draggable: false, +}; + +const BUTTON_CLASS = "m-4 w-60 flex justify-center"; + interface DisplayStageProps { filename: string; link: string; @@ -25,43 +38,30 @@ export function DisplayStage({ guestHint, }: DisplayStageProps): JSX.Element { const isDark = useIsDarkTheme(); + const previewRef = useRef(null); const downloadPNG = () => { - saveSvgAsPng( - document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild - ?.firstElementChild as HTMLElement, - `${filename}.png`, - { - scale: 2, - encoderOptions: 1, - }, - ); - }; - - const copyMarkdown = () => { - void navigator.clipboard.writeText( - `[![GitHub Stats](${card.toApiUrl(HOST)})](${link})`, - ); - toast.info("Copied to Clipboard!", { - position: "bottom-right", - autoClose: 1500, - hideProgressBar: true, - closeOnClick: false, - pauseOnHover: true, - draggable: false, + const svg = previewRef.current?.shadowRoot?.querySelector("svg"); + if (!svg) { + toast.error("The card is not ready yet.", TOAST_OPTIONS); + return; + } + downloadSvgAsPng(svg, `${filename}.png`).catch((error: unknown) => { + console.error(error); + toast.error("Could not download the card as a PNG.", TOAST_OPTIONS); }); }; - const copyUrl = () => { - void navigator.clipboard.writeText(card.toApiUrl(HOST)); - toast.info("Copied to Clipboard!", { - position: "bottom-right", - autoClose: 1500, - hideProgressBar: true, - closeOnClick: false, - pauseOnHover: true, - draggable: false, - }); + const copy = (text: string) => { + navigator.clipboard + .writeText(text) + .then(() => { + toast.info("Copied to Clipboard!", TOAST_OPTIONS); + }) + .catch((error: unknown) => { + console.error(error); + toast.error("Could not copy to the clipboard.", TOAST_OPTIONS); + }); }; return ( @@ -69,34 +69,35 @@ export function DisplayStage({
- {[ - { - title: "Copy Markdown", - highlight: true, - onClick: copyMarkdown, - }, - { - title: "Copy URL", - highlight: false, - onClick: copyUrl, - }, - { - title: "Download PNG", - highlight: false, - onClick: downloadPNG, - }, - ].map((item) => ( - - ))} + + +
- {!!guestHint &&
{guestHint}
} + {!!guestHint && ( +
{guestHint}
+ )}
@@ -108,6 +109,7 @@ export function DisplayStage({ card={card.disableAnimations()} stage={4} className="flex justify-center" + ref={previewRef} />
diff --git a/apps/frontend/src/wizard/components/Card/CardImage.tsx b/apps/frontend/src/wizard/components/Card/CardImage.tsx index 09519100c6324..3211e1b967365 100644 --- a/apps/frontend/src/wizard/components/Card/CardImage.tsx +++ b/apps/frontend/src/wizard/components/Card/CardImage.tsx @@ -1,4 +1,5 @@ import { clsx } from "clsx"; +import type { Ref } from "react"; import { HOST } from "../../../constants"; import type { CardUrlBuilder } from "../../models/CardUrl"; @@ -10,6 +11,8 @@ interface CardImageProps { stage: number; compact?: boolean; className?: string; + /** Forwarded to `SvgInline`'s shadow-root host. */ + ref?: Ref; } export const CardImage = ({ @@ -17,6 +20,7 @@ export const CardImage = ({ stage, compact = false, className, + ref, }: CardImageProps) => { // `client=wizard` marks requests coming from the wizard preview. const fullImageSrc = card.client("wizard").toApiUrl(HOST); @@ -28,6 +32,7 @@ export const CardImage = ({ url={fullImageSrc} compact={compact} stage={stage} + ref={ref} /> ); diff --git a/apps/frontend/src/wizard/components/Card/SvgInline.tsx b/apps/frontend/src/wizard/components/Card/SvgInline.tsx index c5cd9b47307a0..0f2fb78a47571 100644 --- a/apps/frontend/src/wizard/components/Card/SvgInline.tsx +++ b/apps/frontend/src/wizard/components/Card/SvgInline.tsx @@ -2,8 +2,8 @@ import { router } from "@stats-organization/github-readme-stats-backend"; import { loadConfigFromEnv } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; -import { useEffect, useRef, useState } from "react"; -import type { JSX } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { JSX, Ref, RefCallback } from "react"; import Skeleton from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; @@ -20,6 +20,8 @@ interface SvgInlineProps { compact?: boolean; className?: string; forceLoading?: boolean; + /** Receives the shadow-root host, so a caller can reach the rendered ``. */ + ref?: Ref | undefined; } export function SvgInline(props: SvgInlineProps): JSX.Element { @@ -29,11 +31,24 @@ export function SvgInline(props: SvgInlineProps): JSX.Element { className, compact = false, forceLoading = false, + ref, } = props; const [svg, setSvg] = useState(null); const [loaded, setLoaded] = useState(false); - const containerRef = useRef(null); + const containerRef = useRef(null); + + const setContainer = useCallback>( + (node) => { + containerRef.current = node; + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }, + [ref], + ); const userToken = useUserToken(); const isAuthenticated = useIsAuthenticated(); @@ -117,7 +132,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element { if (forceLoading || !loaded) { if (compact) { return ( - + ); } // maximum dimensions of cards in SelectCard stage @@ -132,9 +147,9 @@ export function SvgInline(props: SvgInlineProps): JSX.Element { // Using a different key than the skeletons above to ensure react doesn't reuse the node, which would keep its old shadow DOM content visible. return (
); diff --git a/apps/frontend/src/wizard/components/Card/downloadSvgAsPng.ts b/apps/frontend/src/wizard/components/Card/downloadSvgAsPng.ts new file mode 100644 index 0000000000000..d5eb4deeb3034 --- /dev/null +++ b/apps/frontend/src/wizard/components/Card/downloadSvgAsPng.ts @@ -0,0 +1,54 @@ +/** + * Convert a self-contained SVG element to a PNG and hand it to the browser as a download. + * The card SVGs carry their own `