375 lines
9.3 KiB
TypeScript
375 lines
9.3 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
|
|
import qrcodegen from "./codegen";
|
|
import {
|
|
DEFAULT_BGCOLOR,
|
|
DEFAULT_FGCOLOR,
|
|
DEFAULT_LEVEL,
|
|
DEFAULT_MARGIN,
|
|
DEFAULT_SIZE,
|
|
ERROR_LEVEL_MAP,
|
|
WRDO_QR_LOGO,
|
|
} from "./constants";
|
|
import { QRProps, QRPropsCanvas } from "./types";
|
|
import {
|
|
excavateModules,
|
|
generatePath,
|
|
getImageSettings,
|
|
SUPPORTS_PATH2D,
|
|
} from "./utils";
|
|
|
|
export * from "./types";
|
|
export * from "./utils";
|
|
|
|
export function QRCodeCanvas(props: QRPropsCanvas) {
|
|
const {
|
|
value,
|
|
size = DEFAULT_SIZE,
|
|
level = DEFAULT_LEVEL,
|
|
bgColor = DEFAULT_BGCOLOR,
|
|
fgColor = DEFAULT_FGCOLOR,
|
|
margin = DEFAULT_MARGIN,
|
|
style,
|
|
imageSettings,
|
|
...otherProps
|
|
} = props;
|
|
const imgSrc = imageSettings?.src;
|
|
const _canvas = useRef<HTMLCanvasElement>(null);
|
|
const _image = useRef<HTMLImageElement>(null);
|
|
|
|
// We're just using this state to trigger rerenders when images load. We
|
|
// Don't actually read the value anywhere. A smarter use of useEffect would
|
|
// depend on this value.
|
|
const [isImgLoaded, setIsImageLoaded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Always update the canvas. It's cheap enough and we want to be correct
|
|
// with the current state.
|
|
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 numCells = cells.length + margin * 2;
|
|
const calculatedImageSettings = getImageSettings(
|
|
cells,
|
|
size,
|
|
margin,
|
|
imageSettings,
|
|
);
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Ensure we mark image loaded as false here so we trigger updating the
|
|
// canvas in our other effect.
|
|
useEffect(() => {
|
|
setIsImageLoaded(false);
|
|
}, [imgSrc]);
|
|
|
|
const canvasStyle = { height: size, width: size, ...style };
|
|
let img: JSX.Element | null = null;
|
|
if (imgSrc != null) {
|
|
img = (
|
|
<img
|
|
alt="QR code"
|
|
src={imgSrc}
|
|
key={imgSrc}
|
|
style={{ display: "none" }}
|
|
onLoad={() => {
|
|
setIsImageLoaded(true);
|
|
}}
|
|
ref={_image}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<canvas
|
|
style={canvasStyle}
|
|
height={size}
|
|
width={size}
|
|
ref={_canvas}
|
|
{...otherProps}
|
|
/>
|
|
{img}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export async function getQRAsSVGDataUri(props: QRProps) {
|
|
const {
|
|
value,
|
|
size = DEFAULT_SIZE,
|
|
level = DEFAULT_LEVEL,
|
|
bgColor = DEFAULT_BGCOLOR,
|
|
fgColor = DEFAULT_FGCOLOR,
|
|
margin = DEFAULT_MARGIN,
|
|
imageSettings,
|
|
} = props;
|
|
|
|
let cells = qrcodegen.QrCode.encodeText(
|
|
value,
|
|
ERROR_LEVEL_MAP[level],
|
|
).getModules();
|
|
|
|
const numCells = cells.length + margin * 2;
|
|
const calculatedImageSettings = getImageSettings(
|
|
cells,
|
|
size,
|
|
margin,
|
|
imageSettings,
|
|
);
|
|
|
|
let image = "";
|
|
if (imageSettings != null && calculatedImageSettings != null) {
|
|
if (calculatedImageSettings.excavation != null)
|
|
cells = excavateModules(cells, calculatedImageSettings.excavation);
|
|
|
|
const base64Image = await getBase64Image(imageSettings.src);
|
|
|
|
image = [
|
|
`<image href="${base64Image}"`,
|
|
`height="${calculatedImageSettings.h}"`,
|
|
`width="${calculatedImageSettings.w}"`,
|
|
`x="${calculatedImageSettings.x + margin}"`,
|
|
`y="${calculatedImageSettings.y + margin}"`,
|
|
'preserveAspectRatio="none"></image>',
|
|
].join(" ");
|
|
}
|
|
|
|
const fgPath = generatePath(cells, margin);
|
|
|
|
const svgData = [
|
|
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
|
|
`<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shapeRendering="crispEdges"></path>`,
|
|
`<path fill="${fgColor}" d="${fgPath}" shapeRendering="crispEdges"></path>`,
|
|
image,
|
|
"</svg>",
|
|
].join("");
|
|
|
|
return `data:image/svg+xml,${encodeURIComponent(svgData)}`;
|
|
}
|
|
|
|
const getBase64Image = (imgUrl: string) => {
|
|
return new Promise(function (resolve, reject) {
|
|
const img = new Image();
|
|
img.src = imgUrl;
|
|
img.setAttribute("crossOrigin", "anonymous");
|
|
|
|
img.onload = function () {
|
|
const canvas = document.createElement("canvas");
|
|
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(img, 0, 0);
|
|
|
|
const dataURL = canvas.toDataURL("image/png");
|
|
resolve(dataURL);
|
|
};
|
|
|
|
img.onerror = function () {
|
|
reject("The image could not be loaded.");
|
|
};
|
|
});
|
|
};
|
|
|
|
function waitUntilImageLoaded(img: HTMLImageElement, src: string) {
|
|
return new Promise((resolve) => {
|
|
function onFinish() {
|
|
img.onload = null;
|
|
img.onerror = null;
|
|
resolve(true);
|
|
}
|
|
img.onload = onFinish;
|
|
img.onerror = onFinish;
|
|
img.src = src;
|
|
img.loading = "eager";
|
|
});
|
|
}
|
|
|
|
export async function getQRAsCanvas(
|
|
props: QRProps,
|
|
type: string,
|
|
getCanvas?: boolean,
|
|
): Promise<HTMLCanvasElement | string> {
|
|
const {
|
|
value,
|
|
size = DEFAULT_SIZE,
|
|
level = DEFAULT_LEVEL,
|
|
bgColor = DEFAULT_BGCOLOR,
|
|
fgColor = DEFAULT_FGCOLOR,
|
|
margin = DEFAULT_MARGIN,
|
|
imageSettings,
|
|
} = props;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
|
|
|
|
let cells = qrcodegen.QrCode.encodeText(
|
|
value,
|
|
ERROR_LEVEL_MAP[level],
|
|
).getModules();
|
|
const numCells = cells.length + margin * 2;
|
|
const calculatedImageSettings = getImageSettings(
|
|
cells,
|
|
size,
|
|
margin,
|
|
imageSettings,
|
|
);
|
|
|
|
const image = new Image();
|
|
image.crossOrigin = "anonymous";
|
|
if (calculatedImageSettings) {
|
|
// @ts-expect-error: imageSettings is not null
|
|
await waitUntilImageLoaded(image, imageSettings.src);
|
|
if (calculatedImageSettings.excavation != null) {
|
|
cells = excavateModules(cells, calculatedImageSettings.excavation);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const haveImageToRender =
|
|
calculatedImageSettings != null &&
|
|
image !== null &&
|
|
image.complete &&
|
|
image.naturalHeight !== 0 &&
|
|
image.naturalWidth !== 0;
|
|
if (haveImageToRender) {
|
|
ctx.drawImage(
|
|
image,
|
|
calculatedImageSettings.x + margin,
|
|
calculatedImageSettings.y + margin,
|
|
calculatedImageSettings.w,
|
|
calculatedImageSettings.h,
|
|
);
|
|
}
|
|
|
|
if (getCanvas) return canvas;
|
|
|
|
const url = canvas.toDataURL(type, 1.0);
|
|
canvas.remove();
|
|
image.remove();
|
|
return url;
|
|
}
|
|
|
|
export function getQRData({
|
|
url,
|
|
fgColor,
|
|
bgColor,
|
|
hideLogo,
|
|
logo,
|
|
margin,
|
|
size = 600,
|
|
level,
|
|
}: {
|
|
url: string;
|
|
fgColor?: string;
|
|
bgColor?: string;
|
|
hideLogo?: boolean;
|
|
logo?: string;
|
|
margin?: number;
|
|
size?: number;
|
|
level?: string;
|
|
}) {
|
|
return {
|
|
value: `${url}?qr=1`,
|
|
bgColor,
|
|
fgColor,
|
|
size,
|
|
level, // QR Code error correction level: https://blog.qrstuff.com/general/qr-code-error-correction
|
|
hideLogo,
|
|
margin,
|
|
...(!hideLogo && {
|
|
imageSettings: {
|
|
src: logo || WRDO_QR_LOGO,
|
|
height: size / 4,
|
|
width: size / 4,
|
|
excavate: true,
|
|
},
|
|
}),
|
|
};
|
|
}
|