357 lines
9.1 KiB
TypeScript
357 lines
9.1 KiB
TypeScript
/*
|
|
This file is part of GNU Taler
|
|
(C) 2022 Taler Systems S.A.
|
|
|
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
|
terms of the GNU General Public License as published by the Free Software
|
|
Foundation; either version 3, or (at your option) any later version.
|
|
|
|
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License along with
|
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
|
*/
|
|
|
|
import {
|
|
parseTalerUri,
|
|
TalerUri,
|
|
TranslatedString,
|
|
} from "@gnu-taler/taler-util";
|
|
import { useTranslationContext } from "@gnu-taler/web-util/browser";
|
|
import { css } from "@linaria/core";
|
|
import { styled } from "@linaria/react";
|
|
import jsQR, * as pr from "jsqr";
|
|
import { Fragment, h, VNode } from "preact";
|
|
import { useRef, useState } from "preact/hooks";
|
|
import { Alert } from "../mui/Alert.js";
|
|
import { Button } from "../mui/Button.js";
|
|
import { Grid } from "../mui/Grid.js";
|
|
import { InputFile } from "../mui/InputFile.js";
|
|
import { TextField } from "../mui/TextField.js";
|
|
|
|
const QrCanvas = css`
|
|
width: 80%;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
padding: 8px;
|
|
background-color: black;
|
|
`;
|
|
|
|
const LINE_COLOR = "#FF3B58";
|
|
|
|
const Container = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
& > * {
|
|
margin-bottom: 20px;
|
|
}
|
|
`;
|
|
|
|
export interface Props {
|
|
onDetected: (url: TalerUri) => void;
|
|
}
|
|
|
|
type XY = { x: number; y: number };
|
|
|
|
function drawLine(
|
|
canvas: CanvasRenderingContext2D,
|
|
begin: XY,
|
|
end: XY,
|
|
color: string,
|
|
) {
|
|
canvas.beginPath();
|
|
canvas.moveTo(begin.x, begin.y);
|
|
canvas.lineTo(end.x, end.y);
|
|
canvas.lineWidth = 4;
|
|
canvas.strokeStyle = color;
|
|
canvas.stroke();
|
|
}
|
|
|
|
function drawBox(context: CanvasRenderingContext2D, code: pr.QRCode) {
|
|
drawLine(
|
|
context,
|
|
code.location.topLeftCorner,
|
|
code.location.topRightCorner,
|
|
LINE_COLOR,
|
|
);
|
|
drawLine(
|
|
context,
|
|
code.location.topRightCorner,
|
|
code.location.bottomRightCorner,
|
|
LINE_COLOR,
|
|
);
|
|
drawLine(
|
|
context,
|
|
code.location.bottomRightCorner,
|
|
code.location.bottomLeftCorner,
|
|
LINE_COLOR,
|
|
);
|
|
drawLine(
|
|
context,
|
|
code.location.bottomLeftCorner,
|
|
code.location.topLeftCorner,
|
|
LINE_COLOR,
|
|
);
|
|
}
|
|
|
|
const SCAN_PER_SECONDS = 3;
|
|
const TIME_BETWEEN_FRAMES = 1000 / SCAN_PER_SECONDS;
|
|
|
|
async function delay(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function drawIntoCanvasAndGetQR(
|
|
tag: HTMLVideoElement | HTMLImageElement,
|
|
canvas: HTMLCanvasElement,
|
|
): string | undefined {
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw Error("no 2d canvas context");
|
|
}
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
context.drawImage(tag, 0, 0, canvas.width, canvas.height);
|
|
const imgData = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
const code = jsQR.default(imgData.data, canvas.width, canvas.height, {
|
|
inversionAttempts: "attemptBoth",
|
|
});
|
|
if (code) {
|
|
drawBox(context, code);
|
|
return code.data;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function readNextFrame(
|
|
video: HTMLVideoElement,
|
|
canvas: HTMLCanvasElement,
|
|
): Promise<string | undefined> {
|
|
const requestFrame =
|
|
"requestVideoFrameCallback" in video
|
|
? video.requestVideoFrameCallback.bind(video)
|
|
: requestAnimationFrame;
|
|
|
|
return new Promise<string | undefined>((ok, bad) => {
|
|
requestFrame(() => {
|
|
try {
|
|
const code = drawIntoCanvasAndGetQR(video, canvas);
|
|
ok(code);
|
|
} catch (error) {
|
|
bad(error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function createCanvasFromVideo(
|
|
video: HTMLVideoElement,
|
|
canvas: HTMLCanvasElement,
|
|
): Promise<string> {
|
|
const context = canvas.getContext("2d", {
|
|
willReadFrequently: true,
|
|
});
|
|
if (!context) {
|
|
throw Error("no 2d canvas context");
|
|
}
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
|
|
let last = Date.now();
|
|
|
|
let found: string | undefined = undefined;
|
|
while (!found) {
|
|
const timeSinceLast = Date.now() - last;
|
|
if (timeSinceLast < TIME_BETWEEN_FRAMES) {
|
|
await delay(TIME_BETWEEN_FRAMES - timeSinceLast);
|
|
}
|
|
last = Date.now();
|
|
found = await readNextFrame(video, canvas);
|
|
}
|
|
video.pause();
|
|
return found;
|
|
}
|
|
|
|
async function createCanvasFromFile(
|
|
source: string,
|
|
canvas: HTMLCanvasElement,
|
|
): Promise<string | undefined> {
|
|
const img = new Image(300, 300);
|
|
img.src = source;
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
return new Promise<string | undefined>((ok, bad) => {
|
|
img.addEventListener("load", (e) => {
|
|
try {
|
|
const code = drawIntoCanvasAndGetQR(img, canvas);
|
|
ok(code);
|
|
} catch (error) {
|
|
bad(error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitUntilReady(video: HTMLVideoElement): Promise<void> {
|
|
return new Promise((ok, bad) => {
|
|
if (video.readyState === video.HAVE_ENOUGH_DATA) {
|
|
return ok();
|
|
}
|
|
setTimeout(waitUntilReady, 100);
|
|
});
|
|
}
|
|
|
|
export function QrReaderPage({ onDetected }: Props): VNode {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [error, setError] = useState<TranslatedString | undefined>();
|
|
const [value, setValue] = useState("");
|
|
const [show, setShow] = useState<"canvas" | "video" | "nothing">("nothing");
|
|
|
|
const { i18n } = useTranslationContext();
|
|
|
|
function onChange(str: string) {
|
|
if (!!str) {
|
|
if (!parseTalerUri(str)) {
|
|
setError(
|
|
i18n.str`URI is not valid. Taler URI should start with "taler://"`,
|
|
);
|
|
} else {
|
|
setError(undefined);
|
|
}
|
|
} else {
|
|
setError(undefined);
|
|
}
|
|
setValue(str);
|
|
}
|
|
|
|
async function startVideo() {
|
|
if (!videoRef.current || !canvasRef.current) {
|
|
return;
|
|
}
|
|
const video = videoRef.current;
|
|
if (!video || !video.played) return;
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: "environment" },
|
|
audio: false,
|
|
});
|
|
setShow("video");
|
|
setError(undefined);
|
|
video.srcObject = stream;
|
|
await video.play();
|
|
await waitUntilReady(video);
|
|
try {
|
|
const code = await createCanvasFromVideo(video, canvasRef.current);
|
|
if (code) {
|
|
onChange(code);
|
|
setShow("canvas");
|
|
}
|
|
stream.getTracks().forEach((e) => {
|
|
e.stop();
|
|
});
|
|
} catch (error) {
|
|
setError(i18n.str`something unexpected happen: ${error}`);
|
|
}
|
|
}
|
|
|
|
async function onFileRead(fileContent: string) {
|
|
if (!canvasRef.current) {
|
|
return;
|
|
}
|
|
setShow("nothing");
|
|
setError(undefined);
|
|
try {
|
|
const code = await createCanvasFromFile(fileContent, canvasRef.current);
|
|
if (code) {
|
|
onChange(code);
|
|
setShow("canvas");
|
|
} else {
|
|
setError(i18n.str`Could not found a QR code in the file`);
|
|
}
|
|
} catch (error) {
|
|
setError(i18n.str`something unexpected happen: ${error}`);
|
|
}
|
|
}
|
|
|
|
const active = value === "";
|
|
return (
|
|
<Container>
|
|
<section>
|
|
<h1>
|
|
<i18n.Translate>
|
|
Scan a QR code or enter taler:// URI below
|
|
</i18n.Translate>
|
|
</h1>
|
|
|
|
<p>
|
|
<TextField
|
|
label="Taler URI"
|
|
variant="standard"
|
|
fullWidth
|
|
value={value}
|
|
onChange={onChange}
|
|
/>
|
|
</p>
|
|
<Grid container justifyContent="space-around" columns={2}>
|
|
<Grid item xs={2}>
|
|
<p>{error && <Alert severity="error">{error}</Alert>}</p>
|
|
</Grid>
|
|
<Grid item xs={1}>
|
|
{!active && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={async () => {
|
|
setShow("nothing");
|
|
onChange("");
|
|
}}
|
|
color="error"
|
|
>
|
|
<i18n.Translate>Clear</i18n.Translate>
|
|
</Button>
|
|
)}
|
|
</Grid>
|
|
<Grid item xs={1}>
|
|
{value && (
|
|
<Button
|
|
disabled={!!error}
|
|
variant="contained"
|
|
color="success"
|
|
onClick={async () => {
|
|
const uri = parseTalerUri(value);
|
|
if (uri) onDetected(uri);
|
|
}}
|
|
>
|
|
<i18n.Translate>Open</i18n.Translate>
|
|
</Button>
|
|
)}
|
|
</Grid>
|
|
<Grid item xs={1}>
|
|
<InputFile onChange={onFileRead}>Read QR from file</InputFile>
|
|
</Grid>
|
|
<Grid item xs={1}>
|
|
<p>
|
|
<Button variant="contained" onClick={startVideo}>
|
|
Use Camera
|
|
</Button>
|
|
</p>
|
|
</Grid>
|
|
</Grid>
|
|
</section>
|
|
<div>
|
|
<video
|
|
ref={videoRef}
|
|
style={{ display: show === "video" ? "unset" : "none" }}
|
|
playsInline={true}
|
|
/>
|
|
<canvas
|
|
id="este"
|
|
class={QrCanvas}
|
|
ref={canvasRef}
|
|
style={{ display: show === "canvas" ? "unset " : "none" }}
|
|
/>
|
|
</div>
|
|
</Container>
|
|
);
|
|
}
|