Skip to content

Commit

Permalink
separate components in different files
Browse files Browse the repository at this point in the history
  • Loading branch information
caub committed Apr 20, 2022
1 parent 9a82c5e commit b17dbd0
Show file tree
Hide file tree
Showing 4 changed files with 363 additions and 349 deletions.
145 changes: 145 additions & 0 deletions src/canvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React, {useRef, useEffect} from 'react';
import qrcodegen from './third-party/qrcodegen';
import { QRProps, DEFAULT_PROPS, MARGIN_SIZE, ERROR_LEVEL_MAP, generatePath, excavateModules, getImageSettings } from './helpers';


// For canvas we're going to switch our drawing mode based on whether or not
// the environment supports Path2D. We only need the constructor to be
// supported, but Edge doesn't actually support the path (string) type
// argument. Luckily it also doesn't support the addPath() method. We can
// treat that as the same thing.
const SUPPORTS_PATH2D = (function () {
try {
new Path2D().addPath(new Path2D());
} catch (e) {
return false;
}
return true;
})();


export default function QRCodeCanvas(props: QRProps) {
const _canvas = useRef<HTMLCanvasElement>(null);
const _image = useRef<HTMLImageElement>(null);

function update() {
const {value, size, level, bgColor, fgColor, includeMargin} = props;

if (_canvas.current != null) {
const canvas = _canvas.current;

const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}

let cells = qrcodegen.QrCode.encodeText(
value,
ERROR_LEVEL_MAP[level]
).getModules();

const margin = includeMargin ? MARGIN_SIZE : 0;
const numCells = cells.length + margin * 2;
const calculatedImageSettings = getImageSettings(props, cells);

const image = _image.current;
const haveImageToRender =
calculatedImageSettings != null &&
image !== null &&
image.complete &&
image.naturalHeight !== 0 &&
image.naturalWidth !== 0;

if (haveImageToRender) {
if (calculatedImageSettings.excavation != null) {
cells = excavateModules(cells, calculatedImageSettings.excavation);
}
}

// We're going to scale this so that the number of drawable units
// matches the number of cells. This avoids rounding issues, but does
// result in some potentially unwanted single pixel issues between
// blocks, only in environments that don't support Path2D.
const pixelRatio = window.devicePixelRatio || 1;
canvas.height = canvas.width = size * pixelRatio;
const scale = (size / numCells) * pixelRatio;
ctx.scale(scale, scale);

// Draw solid background, only paint dark modules.
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, numCells, numCells);

ctx.fillStyle = fgColor;
if (SUPPORTS_PATH2D) {
// $FlowFixMe: Path2D c'tor doesn't support args yet.
ctx.fill(new Path2D(generatePath(cells, margin)));
} else {
cells.forEach(function (row, rdx) {
row.forEach(function (cell, cdx) {
if (cell) {
ctx.fillRect(cdx + margin, rdx + margin, 1, 1);
}
});
});
}

if (haveImageToRender) {
ctx.drawImage(
image,
calculatedImageSettings.x + margin,
calculatedImageSettings.y + margin,
calculatedImageSettings.w,
calculatedImageSettings.h
);
}
}
}

useEffect(() => {
// Always update the canvas. It's cheap enough and we want to be correct
// with the current state.
update();
});

const {
value,
size,
level,
bgColor,
fgColor,
style,
includeMargin,
imageSettings,
...otherProps
} = props;
const canvasStyle = {height: size, width: size, ...style};
let img = null;
let imgSrc = imageSettings?.src;
if (imgSrc != null) {
img = (
<img
src={imgSrc}
key={imgSrc}
style={{display: 'none'}}
onLoad={() => {
update();
}}
ref={_image}
/>
);
}
return (
<>
<canvas
style={canvasStyle}
height={size}
width={size}
ref={_canvas}
{...otherProps}
/>
{img}
</>
);
}

QRCodeCanvas.defaultProps = DEFAULT_PROPS;
148 changes: 148 additions & 0 deletions src/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import qrcodegen from './third-party/qrcodegen';
import type {CSSProperties} from 'react';

type Modules = ReturnType<qrcodegen.QrCode['getModules']>;
type Excavation = {x: number; y: number; w: number; h: number};

export const ERROR_LEVEL_MAP: {[index: string]: qrcodegen.QrCode.Ecc} = {
L: qrcodegen.QrCode.Ecc.LOW,
M: qrcodegen.QrCode.Ecc.MEDIUM,
Q: qrcodegen.QrCode.Ecc.QUARTILE,
H: qrcodegen.QrCode.Ecc.HIGH,
};

export type QRProps = {
value: string;
size: number;
// Should be a real enum, but doesn't seem to be compatible with real code.
level: string;
bgColor: string;
fgColor: string;
style?: CSSProperties;
includeMargin: boolean;
imageSettings?: {
src: string;
height: number;
width: number;
excavate: boolean;
x?: number;
y?: number;
};
};

export const DEFAULT_PROPS = {
size: 128,
level: 'L',
bgColor: '#FFFFFF',
fgColor: '#000000',
includeMargin: false,
};

export const MARGIN_SIZE = 4;

// This is *very* rough estimate of max amount of QRCode allowed to be covered.
// It is "wrong" in a lot of ways (area is a terrible way to estimate, it
// really should be number of modules covered), but if for some reason we don't
// get an explicit height or width, I'd rather default to something than throw.
const DEFAULT_IMG_SCALE = 0.1;

export function generatePath(modules: Modules, margin: number = 0): string {
const ops: Array<string> = [];
modules.forEach(function (row, y) {
let start: number | null = null;
row.forEach(function (cell, x) {
if (!cell && start !== null) {
// M0 0h7v1H0z injects the space with the move and drops the comma,
// saving a char per operation
ops.push(
`M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`
);
start = null;
return;
}

// end of row, clean up or skip
if (x === row.length - 1) {
if (!cell) {
// We would have closed the op above already so this can only mean
// 2+ light modules in a row.
return;
}
if (start === null) {
// Just a single dark module.
ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`);
} else {
// Otherwise finish the current line.
ops.push(
`M${start + margin},${y + margin} h${x + 1 - start}v1H${
start + margin
}z`
);
}
return;
}

if (cell && start === null) {
start = x;
}
});
});
return ops.join('');
}

// We could just do this in generatePath, except that we want to support
// non-Path2D canvas, so we need to keep it an explicit step.
export function excavateModules(modules: Modules, excavation: Excavation): Modules {
return modules.slice().map((row, y) => {
if (y < excavation.y || y >= excavation.y + excavation.h) {
return row;
}
return row.map((cell, x) => {
if (x < excavation.x || x >= excavation.x + excavation.w) {
return cell;
}
return false;
});
});
}

export function getImageSettings(
props: QRProps,
cells: Modules
): null | {
x: number;
y: number;
h: number;
w: number;
excavation: Excavation | null;
} {
const {imageSettings, size, includeMargin} = props;
if (imageSettings == null) {
return null;
}
const margin = includeMargin ? MARGIN_SIZE : 0;
const numCells = cells.length + margin * 2;
const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE);
const scale = numCells / size;
const w = (imageSettings.width || defaultSize) * scale;
const h = (imageSettings.height || defaultSize) * scale;
const x =
imageSettings.x == null
? cells.length / 2 - w / 2
: imageSettings.x * scale;
const y =
imageSettings.y == null
? cells.length / 2 - h / 2
: imageSettings.y * scale;

let excavation = null;
if (imageSettings.excavate) {
let floorX = Math.floor(x);
let floorY = Math.floor(y);
let ceilW = Math.ceil(w + x - floorX);
let ceilH = Math.ceil(h + y - floorY);
excavation = {x: floorX, y: floorY, w: ceilW, h: ceilH};
}

return {x, y, h, w, excavation};
}
Loading

0 comments on commit b17dbd0

Please sign in to comment.