From 42ec9251afc4b5759717c3ec9b7f02f50ea65dd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:28:20 +0000 Subject: [PATCH 1/3] Add SVG download option to demo page Turn the demo's Download PNG button into a split button with a dropdown offering PNG and SVG. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UUJ8UiTaVBBcDZj7nK9WSi --- apps/docs/package.json | 1 + apps/docs/src/components/demo/demo.tsx | 37 ++++++++-- apps/docs/src/components/ui/dropdown-menu.tsx | 45 ++++++++++++ pnpm-lock.yaml | 70 +++++++++++++++++++ 4 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 apps/docs/src/components/ui/dropdown-menu.tsx diff --git a/apps/docs/package.json b/apps/docs/package.json index 3c6679d..6e2ab5f 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-accordion": "^1.2.21", "@radix-ui/react-checkbox": "^1.3.12", "@radix-ui/react-dialog": "^1.2.0", + "@radix-ui/react-dropdown-menu": "^2.1.25", "@radix-ui/react-label": "^2.1.16", "@radix-ui/react-popover": "^1.2.0", "@radix-ui/react-select": "^2.3.8", diff --git a/apps/docs/src/components/demo/demo.tsx b/apps/docs/src/components/demo/demo.tsx index 3fb164f..ebe7c01 100644 --- a/apps/docs/src/components/demo/demo.tsx +++ b/apps/docs/src/components/demo/demo.tsx @@ -1,11 +1,13 @@ 'use client' import { + type DownloadFileFormat, type FrameSettings, ReactQRCode, type ReactQRCodeProps, type ReactQRCodeRef, } from '@lglab/react-qr-code' +import { ChevronDown } from 'lucide-react' import { type PropsWithChildren, useRef, useState } from 'react' import { @@ -15,6 +17,12 @@ import { AccordionTrigger, } from '@/components/ui/accordion' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { Colors } from './colors' import { DataModules } from './data-modules' @@ -88,6 +96,9 @@ export const Demo = () => { imageSettings: defaultImageSettings, }) + const download = (format: DownloadFileFormat) => + qrRef.current?.download({ name: 'qr-code', format, size: 1000 }) + return (
@@ -138,13 +149,25 @@ export const Demo = () => {
- +
+ + + + + + + download('png')}>PNG + download('svg')}>SVG + + +
) diff --git a/apps/docs/src/components/ui/dropdown-menu.tsx b/apps/docs/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ff05305 --- /dev/null +++ b/apps/docs/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,45 @@ +'use client' + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc417da..95b5b9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.25 + version: 2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-label': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -1280,6 +1283,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.25': + resolution: {integrity: sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.6': resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} peerDependencies: @@ -1324,6 +1340,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.25': + resolution: {integrity: sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.2.0': resolution: {integrity: sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==} peerDependencies: @@ -4134,6 +4163,21 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-dropdown-menu@2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-menu': 2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.3.0)(react@19.3.0)': dependencies: react: 19.3.0 @@ -4167,6 +4211,32 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-menu@2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.4.0(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + aria-hidden: 1.2.6 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-popover@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 From c1a37e07f5331c8cf9e22cd7c52a381a6cd24f1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:46:23 +0000 Subject: [PATCH 2/3] Use SVG logo in demo so SVG downloads stay sharp Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UUJ8UiTaVBBcDZj7nK9WSi --- apps/docs/public/images/logo.svg | 1 + apps/docs/src/components/demo/demo.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 apps/docs/public/images/logo.svg diff --git a/apps/docs/public/images/logo.svg b/apps/docs/public/images/logo.svg new file mode 100644 index 0000000..5eb8b91 --- /dev/null +++ b/apps/docs/public/images/logo.svg @@ -0,0 +1 @@ + diff --git a/apps/docs/src/components/demo/demo.tsx b/apps/docs/src/components/demo/demo.tsx index ebe7c01..5da536a 100644 --- a/apps/docs/src/components/demo/demo.tsx +++ b/apps/docs/src/components/demo/demo.tsx @@ -33,7 +33,7 @@ import { ImageSettingsDemo } from './image-settings' import { MainSettings } from './main-settings' export const defaultImageSettings = { - src: 'https://reactqrcode.com/images/logo-60.png', + src: 'https://reactqrcode.com/images/logo.svg', width: 60, height: 60, excavate: true, From 7500df5047fa93e0f8b3d9ffea0044c0587b6628 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:59:06 +0000 Subject: [PATCH 3/3] fix: embed images in SVG downloads Downloaded SVGs linked to the image URL, so the image was missing when the file was opened offline or printed. Fetch each linked image and inline it as a data URL, keeping the original href if the fetch fails. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UUJ8UiTaVBBcDZj7nK9WSi --- .changeset/svg-download-inline-images.md | 5 ++ packages/react-qr-code/src/react-qr-code.tsx | 2 +- .../react-qr-code/src/utils/download.test.ts | 69 ++++++++++++++++++- packages/react-qr-code/src/utils/download.ts | 35 +++++++++- 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 .changeset/svg-download-inline-images.md diff --git a/.changeset/svg-download-inline-images.md b/.changeset/svg-download-inline-images.md new file mode 100644 index 0000000..fa46c06 --- /dev/null +++ b/.changeset/svg-download-inline-images.md @@ -0,0 +1,5 @@ +--- +'@lglab/react-qr-code': patch +--- + +Embed images in SVG downloads as data URLs, so a downloaded SVG with an image still shows it offline and when printed. If the image can't be fetched (for example because of CORS), the original URL is kept. diff --git a/packages/react-qr-code/src/react-qr-code.tsx b/packages/react-qr-code/src/react-qr-code.tsx index 4b05192..563d92b 100644 --- a/packages/react-qr-code/src/react-qr-code.tsx +++ b/packages/react-qr-code/src/react-qr-code.tsx @@ -66,7 +66,7 @@ const ReactQRCode = forwardRef((props, ref) => if (!svgRef.current) return if (fileFormat === 'svg') { - downloadSVG({ svgRef, fileSize, fileName }) + void downloadSVG({ svgRef, fileSize, fileName }) } else { downloadRaster({ svgRef, diff --git a/packages/react-qr-code/src/utils/download.test.ts b/packages/react-qr-code/src/utils/download.test.ts index c51c2f8..fc3adaa 100644 --- a/packages/react-qr-code/src/utils/download.test.ts +++ b/packages/react-qr-code/src/utils/download.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_BGCOLOR } from '../constants' import type { DownloadFileFormat } from '../types/lib' -import { downloadRaster, getFileHeight } from './download' +import { downloadRaster, getFileHeight, inlineImages } from './download' const svgWithViewBox = (width: number, height: number) => ({ viewBox: { baseVal: { width, height } } }) as unknown as SVGSVGElement @@ -70,3 +70,70 @@ describe('downloadRaster', () => { expect(ctx.fillRect).not.toHaveBeenCalled() }) }) + +describe('inlineImages', () => { + const svgWithImage = (href: string) => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const image = document.createElementNS('http://www.w3.org/2000/svg', 'image') + image.setAttribute('href', href) + svg.appendChild(image) + return { svg, image } + } + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('replaces linked images with data URLs', async () => { + const blob = new Blob([''], { type: 'image/svg+xml' }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, blob: async () => blob })), + ) + const { svg, image } = svgWithImage('https://example.com/logo.svg') + await inlineImages(svg) + expect(image.getAttribute('href')).toMatch(/^data:image\/svg\+xml;base64,/) + }) + + it('keeps the original href when the fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('Failed to fetch') + }), + ) + const { svg, image } = svgWithImage('https://example.com/logo.svg') + await inlineImages(svg) + expect(image.getAttribute('href')).toBe('https://example.com/logo.svg') + }) + + it('keeps the original href on an HTTP error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 404 })), + ) + const { svg, image } = svgWithImage('https://example.com/missing.svg') + await inlineImages(svg) + expect(image.getAttribute('href')).toBe('https://example.com/missing.svg') + }) + + it('sends credentials when the image uses use-credentials', async () => { + const blob = new Blob([''], { type: 'image/svg+xml' }) + const fetchMock = vi.fn(async () => ({ ok: true, blob: async () => blob })) + vi.stubGlobal('fetch', fetchMock) + const { svg, image } = svgWithImage('https://example.com/logo.svg') + image.setAttribute('crossorigin', 'use-credentials') + await inlineImages(svg) + expect(fetchMock).toHaveBeenCalledWith('https://example.com/logo.svg', { + credentials: 'include', + }) + }) + + it('leaves data URLs untouched', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const { svg } = svgWithImage('data:image/png;base64,AAAA') + await inlineImages(svg) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-qr-code/src/utils/download.ts b/packages/react-qr-code/src/utils/download.ts index 36337f0..12a56f7 100644 --- a/packages/react-qr-code/src/utils/download.ts +++ b/packages/react-qr-code/src/utils/download.ts @@ -7,12 +7,45 @@ export const getFileHeight = (svg: SVGSVGElement, fileSize: number) => { return width && height ? Math.round((fileSize * height) / width) : fileSize } -export const downloadSVG = ({ svgRef, fileSize, fileName }: DownloadSVGProps) => { +const toDataURL = async (src: string, credentials: RequestCredentials) => { + const response = await fetch(src, { credentials }) + if (!response.ok) throw new Error(`Failed to fetch ${src}: ${response.status}`) + const blob = await response.blob() + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) +} + +// Embed linked images so the downloaded file works offline; keep the link if the fetch fails (e.g. CORS). +export const inlineImages = async (svg: SVGSVGElement) => { + const images = Array.from(svg.querySelectorAll('image')) + await Promise.all( + images.map(async (image) => { + const href = image.getAttribute('href') + if (!href || href.startsWith('data:')) return + try { + const credentials = + image.getAttribute('crossorigin') === 'use-credentials' + ? 'include' + : 'same-origin' + image.setAttribute('href', await toDataURL(href, credentials)) + } catch { + // Keep the original href + } + }), + ) +} + +export const downloadSVG = async ({ svgRef, fileSize, fileName }: DownloadSVGProps) => { if (!svgRef.current) return const clonedSvg = svgRef.current.cloneNode(true) as SVGSVGElement clonedSvg.setAttribute('width', fileSize.toString()) clonedSvg.setAttribute('height', getFileHeight(svgRef.current, fileSize).toString()) + await inlineImages(clonedSvg) const serializer = new XMLSerializer() const svgBlob = new Blob([serializer.serializeToString(clonedSvg)], {