Skip to content

Commit 77efc5e

Browse files
committed
separate components in different files
1 parent 9a82c5e commit 77efc5e

File tree

4 files changed

+377
-349
lines changed

4 files changed

+377
-349
lines changed

src/canvas.tsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, {useRef, useEffect} from 'react';
2+
import qrcodegen from './third-party/qrcodegen';
3+
import {
4+
QRProps,
5+
DEFAULT_PROPS,
6+
MARGIN_SIZE,
7+
ERROR_LEVEL_MAP,
8+
generatePath,
9+
excavateModules,
10+
getImageSettings,
11+
} from './helpers';
12+
13+
// For canvas we're going to switch our drawing mode based on whether or not
14+
// the environment supports Path2D. We only need the constructor to be
15+
// supported, but Edge doesn't actually support the path (string) type
16+
// argument. Luckily it also doesn't support the addPath() method. We can
17+
// treat that as the same thing.
18+
const SUPPORTS_PATH2D = (function () {
19+
try {
20+
new Path2D().addPath(new Path2D());
21+
} catch (e) {
22+
return false;
23+
}
24+
return true;
25+
})();
26+
27+
export default function QRCodeCanvas(props: QRProps) {
28+
const _canvas = useRef<HTMLCanvasElement>(null);
29+
const _image = useRef<HTMLImageElement>(null);
30+
31+
function update() {
32+
const {value, size, level, bgColor, fgColor, includeMargin} = props;
33+
34+
if (_canvas.current != null) {
35+
const canvas = _canvas.current;
36+
37+
const ctx = canvas.getContext('2d');
38+
if (!ctx) {
39+
return;
40+
}
41+
42+
let cells = qrcodegen.QrCode.encodeText(
43+
value,
44+
ERROR_LEVEL_MAP[level]
45+
).getModules();
46+
47+
const margin = includeMargin ? MARGIN_SIZE : 0;
48+
const numCells = cells.length + margin * 2;
49+
const calculatedImageSettings = getImageSettings(props, cells);
50+
51+
const image = _image.current;
52+
const haveImageToRender =
53+
calculatedImageSettings != null &&
54+
image !== null &&
55+
image.complete &&
56+
image.naturalHeight !== 0 &&
57+
image.naturalWidth !== 0;
58+
59+
if (haveImageToRender) {
60+
if (calculatedImageSettings.excavation != null) {
61+
cells = excavateModules(cells, calculatedImageSettings.excavation);
62+
}
63+
}
64+
65+
// We're going to scale this so that the number of drawable units
66+
// matches the number of cells. This avoids rounding issues, but does
67+
// result in some potentially unwanted single pixel issues between
68+
// blocks, only in environments that don't support Path2D.
69+
const pixelRatio = window.devicePixelRatio || 1;
70+
canvas.height = canvas.width = size * pixelRatio;
71+
const scale = (size / numCells) * pixelRatio;
72+
ctx.scale(scale, scale);
73+
74+
// Draw solid background, only paint dark modules.
75+
ctx.fillStyle = bgColor;
76+
ctx.fillRect(0, 0, numCells, numCells);
77+
78+
ctx.fillStyle = fgColor;
79+
if (SUPPORTS_PATH2D) {
80+
// $FlowFixMe: Path2D c'tor doesn't support args yet.
81+
ctx.fill(new Path2D(generatePath(cells, margin)));
82+
} else {
83+
cells.forEach(function (row, rdx) {
84+
row.forEach(function (cell, cdx) {
85+
if (cell) {
86+
ctx.fillRect(cdx + margin, rdx + margin, 1, 1);
87+
}
88+
});
89+
});
90+
}
91+
92+
if (haveImageToRender) {
93+
ctx.drawImage(
94+
image,
95+
calculatedImageSettings.x + margin,
96+
calculatedImageSettings.y + margin,
97+
calculatedImageSettings.w,
98+
calculatedImageSettings.h
99+
);
100+
}
101+
}
102+
}
103+
104+
useEffect(() => {
105+
// Always update the canvas. It's cheap enough and we want to be correct
106+
// with the current state.
107+
update();
108+
});
109+
110+
const {
111+
value,
112+
size,
113+
level,
114+
bgColor,
115+
fgColor,
116+
style,
117+
includeMargin,
118+
imageSettings,
119+
...otherProps
120+
} = props;
121+
const canvasStyle = {height: size, width: size, ...style};
122+
let img = null;
123+
let imgSrc = imageSettings?.src;
124+
if (imgSrc != null) {
125+
img = (
126+
<img
127+
src={imgSrc}
128+
key={imgSrc}
129+
style={{display: 'none'}}
130+
onLoad={() => {
131+
update();
132+
}}
133+
ref={_image}
134+
/>
135+
);
136+
}
137+
return (
138+
<>
139+
<canvas
140+
style={canvasStyle}
141+
height={size}
142+
width={size}
143+
ref={_canvas}
144+
{...otherProps}
145+
/>
146+
{img}
147+
</>
148+
);
149+
}
150+
151+
QRCodeCanvas.defaultProps = DEFAULT_PROPS;
152+

src/helpers.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import qrcodegen from './third-party/qrcodegen';
2+
import type {CSSProperties} from 'react';
3+
4+
type Modules = ReturnType<qrcodegen.QrCode['getModules']>;
5+
type Excavation = {x: number; y: number; w: number; h: number};
6+
7+
export const ERROR_LEVEL_MAP: {[index: string]: qrcodegen.QrCode.Ecc} = {
8+
L: qrcodegen.QrCode.Ecc.LOW,
9+
M: qrcodegen.QrCode.Ecc.MEDIUM,
10+
Q: qrcodegen.QrCode.Ecc.QUARTILE,
11+
H: qrcodegen.QrCode.Ecc.HIGH,
12+
};
13+
14+
export type QRProps = {
15+
value: string;
16+
size: number;
17+
// Should be a real enum, but doesn't seem to be compatible with real code.
18+
level: string;
19+
bgColor: string;
20+
fgColor: string;
21+
style?: CSSProperties;
22+
includeMargin: boolean;
23+
imageSettings?: {
24+
src: string;
25+
height: number;
26+
width: number;
27+
excavate: boolean;
28+
x?: number;
29+
y?: number;
30+
};
31+
};
32+
33+
export const DEFAULT_PROPS = {
34+
size: 128,
35+
level: 'L',
36+
bgColor: '#FFFFFF',
37+
fgColor: '#000000',
38+
includeMargin: false,
39+
};
40+
41+
export const MARGIN_SIZE = 4;
42+
43+
// This is *very* rough estimate of max amount of QRCode allowed to be covered.
44+
// It is "wrong" in a lot of ways (area is a terrible way to estimate, it
45+
// really should be number of modules covered), but if for some reason we don't
46+
// get an explicit height or width, I'd rather default to something than throw.
47+
const DEFAULT_IMG_SCALE = 0.1;
48+
49+
export function generatePath(modules: Modules, margin: number = 0): string {
50+
const ops: Array<string> = [];
51+
modules.forEach(function (row, y) {
52+
let start: number | null = null;
53+
row.forEach(function (cell, x) {
54+
if (!cell && start !== null) {
55+
// M0 0h7v1H0z injects the space with the move and drops the comma,
56+
// saving a char per operation
57+
ops.push(
58+
`M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`
59+
);
60+
start = null;
61+
return;
62+
}
63+
64+
// end of row, clean up or skip
65+
if (x === row.length - 1) {
66+
if (!cell) {
67+
// We would have closed the op above already so this can only mean
68+
// 2+ light modules in a row.
69+
return;
70+
}
71+
if (start === null) {
72+
// Just a single dark module.
73+
ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`);
74+
} else {
75+
// Otherwise finish the current line.
76+
ops.push(
77+
`M${start + margin},${y + margin} h${x + 1 - start}v1H${
78+
start + margin
79+
}z`
80+
);
81+
}
82+
return;
83+
}
84+
85+
if (cell && start === null) {
86+
start = x;
87+
}
88+
});
89+
});
90+
return ops.join('');
91+
}
92+
93+
// We could just do this in generatePath, except that we want to support
94+
// non-Path2D canvas, so we need to keep it an explicit step.
95+
export function excavateModules(modules: Modules, excavation: Excavation): Modules {
96+
return modules.slice().map((row, y) => {
97+
if (y < excavation.y || y >= excavation.y + excavation.h) {
98+
return row;
99+
}
100+
return row.map((cell, x) => {
101+
if (x < excavation.x || x >= excavation.x + excavation.w) {
102+
return cell;
103+
}
104+
return false;
105+
});
106+
});
107+
}
108+
109+
export function getImageSettings(
110+
props: QRProps,
111+
cells: Modules
112+
): null | {
113+
x: number;
114+
y: number;
115+
h: number;
116+
w: number;
117+
excavation: Excavation | null;
118+
} {
119+
const {imageSettings, size, includeMargin} = props;
120+
if (imageSettings == null) {
121+
return null;
122+
}
123+
const margin = includeMargin ? MARGIN_SIZE : 0;
124+
const numCells = cells.length + margin * 2;
125+
const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE);
126+
const scale = numCells / size;
127+
const w = (imageSettings.width || defaultSize) * scale;
128+
const h = (imageSettings.height || defaultSize) * scale;
129+
const x =
130+
imageSettings.x == null
131+
? cells.length / 2 - w / 2
132+
: imageSettings.x * scale;
133+
const y =
134+
imageSettings.y == null
135+
? cells.length / 2 - h / 2
136+
: imageSettings.y * scale;
137+
138+
let excavation = null;
139+
if (imageSettings.excavate) {
140+
let floorX = Math.floor(x);
141+
let floorY = Math.floor(y);
142+
let ceilW = Math.ceil(w + x - floorX);
143+
let ceilH = Math.ceil(h + y - floorY);
144+
excavation = {x: floorX, y: floorY, w: ceilW, h: ceilH};
145+
}
146+
147+
return {x, y, h, w, excavation};
148+
}

0 commit comments

Comments
 (0)