Skip to content

Commit d8efc1f

Browse files
committed
feat: download api
1 parent afaf4ab commit d8efc1f

6 files changed

Lines changed: 196 additions & 14 deletions

File tree

apps/playground/src/App.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {
22
type DataModulesStyle,
3+
type DownloadOptions,
34
type FinderPatternInnerStyle,
45
type FinderPatternOuterStyle,
56
ReactQRCode,
67
type ReactQRCodeProps,
8+
type ReactQRCodeRef,
79
} from '@lglab/react-qr-code'
8-
import { useState } from 'react'
10+
import { useRef, useState } from 'react'
911

1012
import { Select } from './components/ui/select'
1113
import {
@@ -22,7 +24,10 @@ function App() {
2224
const [dataModulesStyle, setDataModulesStyle] = useState<DataModulesStyle>('square')
2325
const [dataModulesRandomSize, setDataModulesRandomSize] = useState<boolean>(false)
2426

27+
const ref = useRef<ReactQRCodeRef>(null)
28+
2529
const qrCodeOptions: ReactQRCodeProps = {
30+
ref,
2631
value: 'https://github.com/LGLabGreg/react-qr-code.git',
2732
size: 500,
2833
marginSize: 3,
@@ -45,8 +50,15 @@ function App() {
4550
width: 60,
4651
height: 60,
4752
excavate: true,
53+
x: 100,
54+
y: 100,
4855
},
4956
}
57+
58+
const download = ({ format = 'svg', size = 400 }: DownloadOptions) => {
59+
ref.current?.download({ format, size, name: 'demo-qr-code' })
60+
}
61+
5062
return (
5163
<div className='max-w-4xl mx-auto px-5 py-8'>
5264
<h1 className='text-4xl font-semibold mb-8 text-center'>React QR Code</h1>
@@ -93,6 +105,9 @@ function App() {
93105
type='checkbox'
94106
/>
95107
</form>
108+
<button onClick={() => download({ format: 'svg' })}>Download SVG</button>
109+
<button onClick={() => download({ format: 'png' })}>Download PNG</button>
110+
<button onClick={() => download({ format: 'jpeg' })}>Download JPEG</button>
96111
</div>
97112
<ReactQRCode {...qrCodeOptions} />
98113
</div>

packages/react-qr-code/src/react-qr-code.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useImperativeHandle, useRef } from 'react'
2+
13
import { DataModules } from './components/data-modules'
24
import { FinderPatternsInner } from './components/finder-patterns-inner'
35
import { FinderPatternsOuter } from './components/finder-patterns-outer'
@@ -8,7 +10,8 @@ import {
810
DEFAULT_SIZE,
911
} from './constants'
1012
import { useQRCode } from './hooks/use-qr-code'
11-
import type { ReactQRCodeProps } from './types/lib'
13+
import type { DownloadOptions, ReactQRCodeProps } from './types/lib'
14+
import { downloadRaster, downloadSVG } from './utils/download'
1215
import { excavateModules } from './utils/qr-code'
1316

1417
const ReactQRCode = (props: ReactQRCodeProps) => {
@@ -28,6 +31,8 @@ const ReactQRCode = (props: ReactQRCodeProps) => {
2831
svgProps,
2932
} = props
3033

34+
const svgRef = useRef<SVGSVGElement | null>(null)
35+
3136
const { margin, cells, numCells, calculatedImageSettings } = useQRCode({
3237
value,
3338
level,
@@ -38,6 +43,33 @@ const ReactQRCode = (props: ReactQRCodeProps) => {
3843
size,
3944
})
4045

46+
useImperativeHandle(ref, () => ({
47+
svg: svgRef.current,
48+
download: ({
49+
name: fileName = 'qr-code',
50+
format: fileFormat = 'svg',
51+
size: fileSize = 500,
52+
}: DownloadOptions) => {
53+
if (!svgRef.current) return
54+
55+
if (fileFormat === 'svg') {
56+
downloadSVG({ svgRef, fileSize, fileName })
57+
} else {
58+
downloadRaster({
59+
svgRef,
60+
fileSize,
61+
fileName,
62+
fileFormat,
63+
imageSettings,
64+
calculatedImageSettings,
65+
size,
66+
numCells,
67+
margin,
68+
})
69+
}
70+
},
71+
}))
72+
4173
let modules = cells
4274
let image = null
4375
if (imageSettings != null && calculatedImageSettings != null) {
@@ -65,7 +97,7 @@ const ReactQRCode = (props: ReactQRCodeProps) => {
6597
height={size}
6698
width={size}
6799
viewBox={`0 0 ${numCells} ${numCells}`}
68-
ref={ref}
100+
ref={svgRef}
69101
role='img'
70102
aria-label={svgProps?.['aria-label'] || 'QR Code'}
71103
{...svgProps}

packages/react-qr-code/src/types/lib.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,20 @@ export interface FinderPatternInnerSettings {
8080
style?: FinderPatternInnerStyle
8181
}
8282

83+
export type DownloadFileFormat = 'svg' | 'png' | 'jpeg'
84+
export interface DownloadOptions {
85+
name?: string
86+
format?: DownloadFileFormat
87+
size?: number
88+
}
89+
90+
export interface ReactQRCodeRef {
91+
svg: SVGSVGElement | null
92+
download: (options: DownloadOptions) => void
93+
}
94+
8395
export interface ReactQRCodeProps {
84-
ref?: Ref<SVGSVGElement>
96+
ref?: Ref<ReactQRCodeRef>
8597
/**
8698
* The value to encode into the QR Code. An array of strings can be passed in
8799
* to represent multiple segments to further optimize the QR Code.

packages/react-qr-code/src/types/utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import { type RefObject } from 'react'
2+
13
import type {
4+
CrossOrigin,
25
DataModulesSettings,
6+
DownloadFileFormat,
7+
Excavation,
38
FinderPatternInnerSettings,
49
FinderPatternOuterSettings,
10+
ImageSettings,
511
Modules,
612
} from './lib'
713

@@ -27,3 +33,31 @@ export interface FinderPatternsInnerProps extends GeneratePathFnProps {
2733
export interface DataModulesProps extends GeneratePathFnProps {
2834
settings?: DataModulesSettings
2935
}
36+
37+
export interface DownloadSVGProps {
38+
svgRef: RefObject<SVGSVGElement | null>
39+
fileSize: number
40+
fileName: string
41+
}
42+
43+
export interface DownloadRasterProps {
44+
svgRef: RefObject<SVGSVGElement | null>
45+
fileSize: number
46+
fileName: string
47+
imageSettings: ImageSettings | undefined
48+
calculatedImageSettings: CalculatedImageSettings | null
49+
fileFormat: DownloadFileFormat
50+
size: number
51+
numCells: number
52+
margin: number
53+
}
54+
55+
export interface CalculatedImageSettings {
56+
x: number
57+
y: number
58+
h: number
59+
w: number
60+
excavation: Excavation | null
61+
opacity: number
62+
crossOrigin: CrossOrigin
63+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { DownloadRasterProps, DownloadSVGProps } from '../types/utils'
2+
3+
export const downloadSVG = ({ svgRef, fileSize, fileName }: DownloadSVGProps) => {
4+
if (!svgRef.current) return
5+
6+
const clonedSvg = svgRef.current.cloneNode(true) as SVGSVGElement
7+
clonedSvg.setAttribute('width', fileSize.toString())
8+
clonedSvg.setAttribute('height', fileSize.toString())
9+
10+
const serializer = new XMLSerializer()
11+
const svgBlob = new Blob([serializer.serializeToString(clonedSvg)], {
12+
type: 'image/svg+xml',
13+
})
14+
const url = URL.createObjectURL(svgBlob)
15+
const a = document.createElement('a')
16+
a.href = url
17+
a.download = `${fileName}.svg`
18+
document.body.appendChild(a)
19+
a.click()
20+
document.body.removeChild(a)
21+
URL.revokeObjectURL(url)
22+
}
23+
24+
export const downloadRaster = ({
25+
svgRef,
26+
fileSize,
27+
fileName,
28+
fileFormat,
29+
imageSettings,
30+
calculatedImageSettings,
31+
size,
32+
numCells,
33+
margin,
34+
}: DownloadRasterProps) => {
35+
if (!svgRef.current) return
36+
37+
const canvas = document.createElement('canvas')
38+
const ctx = canvas.getContext('2d')
39+
if (!ctx) return
40+
41+
canvas.width = fileSize
42+
canvas.height = fileSize
43+
44+
const svgData = new XMLSerializer().serializeToString(svgRef.current)
45+
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
46+
const svgUrl = URL.createObjectURL(svgBlob)
47+
48+
const qrImg = new Image()
49+
qrImg.crossOrigin = 'anonymous'
50+
qrImg.src = svgUrl
51+
52+
qrImg.onload = () => {
53+
ctx.drawImage(qrImg, 0, 0, fileSize, fileSize)
54+
URL.revokeObjectURL(svgUrl)
55+
56+
if (imageSettings?.src && calculatedImageSettings) {
57+
const logoImg = new Image()
58+
logoImg.crossOrigin = 'anonymous'
59+
logoImg.src = imageSettings.src
60+
61+
logoImg.onload = () => {
62+
const ratio = fileSize / size
63+
const scale = numCells / fileSize
64+
65+
const logoSize = imageSettings.width * ratio
66+
const logoX = imageSettings.x
67+
? (calculatedImageSettings.x + margin) / scale
68+
: (fileSize - logoSize) / 2
69+
const logoY = imageSettings.y
70+
? (calculatedImageSettings.y + margin) / scale
71+
: (fileSize - logoSize) / 2
72+
ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize)
73+
74+
const imageType = fileFormat === 'png' ? 'image/png' : 'image/jpeg'
75+
const a = document.createElement('a')
76+
a.href = canvas.toDataURL(imageType)
77+
a.download = `${fileName}.${fileFormat}`
78+
document.body.appendChild(a)
79+
a.click()
80+
document.body.removeChild(a)
81+
}
82+
83+
logoImg.onerror = (err) => console.error('Error loading logo:', err)
84+
} else {
85+
const imageType = fileFormat === 'png' ? 'image/png' : 'image/jpeg'
86+
const a = document.createElement('a')
87+
a.href = canvas.toDataURL(imageType)
88+
a.download = `${fileName}.${fileFormat}`
89+
document.body.appendChild(a)
90+
a.click()
91+
document.body.removeChild(a)
92+
}
93+
}
94+
95+
qrImg.onerror = (err) => console.error('Error loading QR code:', err)
96+
}

packages/react-qr-code/src/utils/qr-code.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DEFAULT_IMG_SCALE, DEFAULT_MARGIN_SIZE } from '../constants'
2-
import type { CrossOrigin, Excavation, ImageSettings, Modules } from '../types/lib'
2+
import type { Excavation, ImageSettings, Modules } from '../types/lib'
3+
import type { CalculatedImageSettings } from '../types/utils'
34

45
export const excavateModules = (modules: Modules, excavation: Excavation): Modules => {
56
return modules.slice().map((row, y) => {
@@ -20,15 +21,7 @@ export const getImageSettings = (
2021
size: number,
2122
margin: number,
2223
imageSettings?: ImageSettings,
23-
): null | {
24-
x: number
25-
y: number
26-
h: number
27-
w: number
28-
excavation: Excavation | null
29-
opacity: number
30-
crossOrigin: CrossOrigin
31-
} => {
24+
): CalculatedImageSettings | null => {
3225
if (imageSettings == null) {
3326
return null
3427
}

0 commit comments

Comments
 (0)