Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QR Scanner React component #16

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/form-compiler/src/inputCompiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { MarkdownInputCompiler } from './markdownInputCompiler';
import { OptionsInputCompiler } from './optionsInputCompiler';
import { SignatureInputCompiler } from './signatureInputCompiler';
import { SampleContainerInputCompiler } from './sampleContainerInputCompiler';
import { QRScannerInputCompiler } from './qrScannerInputCompiler';

export {
AbstractInputCompiler,
Expand All @@ -33,6 +34,8 @@ export {
BooleanInputCompiler,
DateTimeInputCompiler,
SignatureInputCompiler,
QRScannerInputCompiler,
SampleContainerInputCompiler,
};

export const inputCompilers: InputCompiler[] = [
Expand All @@ -49,6 +52,7 @@ export const inputCompilers: InputCompiler[] = [
new CountryInputCompiler(),
new BooleanInputCompiler(),
new DateTimeInputCompiler(),
new QRScannerInputCompiler(),
new SampleContainerInputCompiler(),
new SvgMapInputCompiler(),
new SignatureInputCompiler(),
Expand Down
24 changes: 24 additions & 0 deletions packages/form-compiler/src/inputCompiler/qrScannerInputCompiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { InputCompiler } from '../interfaces';
import { JsonSchema, UISchemaElement } from '@jsonforms/core';
import { Form, Input, InputType, Section } from '@eresearchqut/form-definition';
import { AbstractInputCompiler } from './abstractInputCompiler';

export class QRScannerInputCompiler extends AbstractInputCompiler implements InputCompiler {
supports(form: Form, section: Section, input: Input): boolean {
return input.type === InputType.QR_SCANNER;
}

schema(form: Form, section: Section, input: Input): JsonSchema {
return {
type: 'object',
properties: {
autoStartScanning: { type: 'boolean' },
videoMaxWidthPx: { type: 'number' },
},
} as JsonSchema;
}

ui(form: Form, section: Section, input: Input): UISchemaElement | undefined {
return this.uiControl(form, section, input);
}
}
1 change: 1 addition & 0 deletions packages/form-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"primeflex": "2.0.0",
"primeicons": "5.0.0",
"primereact": "7.1.0",
"qr-scanner": "^1.4.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-img-mapper": "1.2.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react';
import { Meta, Story } from '@storybook/react';
import ReactQRScanner, { ReactQRScannerProps } from './ReactQRScanner';

export default {
title: 'Components/QR Scanner',
component: ReactQRScanner,
} as Meta;

const Template: Story<ReactQRScannerProps> = (props) => <ReactQRScanner {...props} />;

export const NoAutostart = Template.bind({});
NoAutostart.args = { autoStartScanning: false };

export const SmallerVideo = Template.bind({});
SmallerVideo.args = { videoMaxWidthPx: 320 };

export const Default = Template.bind({});
Default.args = {};
169 changes: 169 additions & 0 deletions packages/form-components/src/component/QRCode/ReactQRScanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
import QrScanner from 'qr-scanner';
import { ToggleButton } from 'primereact/togglebutton';
import { Dropdown } from 'primereact/dropdown';

const GENERIC_CAMERAS: QrScanner.Camera[] = [
{ id: 'environment', label: 'Back Camera' },
{ id: 'user', label: 'Front Camera' },
];
const DEFAULT_CAMERA = GENERIC_CAMERAS[0];

export interface ReactQRScannerProps {
autoStartScanning?: boolean;
videoMaxWidthPx?: number | null;
onQRCodeScanned?: (qrCode: string) => void;
}

export const ReactQRScanner: FunctionComponent<ReactQRScannerProps> = ({
autoStartScanning = true,
videoMaxWidthPx = null,
onQRCodeScanned = (qrCode) => {
console.log(`Scanned QR Code: ${qrCode}`);
},
}: ReactQRScannerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const qrScannerRef = useRef<QrScanner | null>(null);
const [availableCameras, setAvailableCameras] = useState<QrScanner.Camera[]>([]);
const [selectedCamera, setSelectedCamera] = useState<QrScanner.Camera>(DEFAULT_CAMERA);
const [lastQRCode, setLastQRCode] = useState<string | null>(null);
const [isStopped, setIsStopped] = useState<boolean>(!autoStartScanning);

async function initQrScanner(video: HTMLVideoElement) {
qrScannerRef.current = new QrScanner(video, onScanned, {
onDecodeError: onScanError,
calculateScanRegion: calculateScanRegion,
highlightScanRegion: true,
highlightCodeOutline: true,
});
qrScannerRef.current?.setInversionMode('both');
}

function cleanUpQrScanner() {
qrScannerRef.current?.stop();
qrScannerRef.current?.destroy();
qrScannerRef.current = null;
}

async function startQrScanner() {
if (qrScannerRef.current === null) return;
try {
await qrScannerRef.current.start();
console.log('QrScanner started');
const cameras = await QrScanner.listCameras(true);
setAvailableCameras(cameras);
} catch (e) {
console.log(`ERROR while starting QR Scanner: ${e}`);
}
}

function calculateScanRegion(video: HTMLVideoElement) {
const smallestDimension = Math.min(video.videoWidth, video.videoHeight);
// Original code: the scan region is two thirds of the smallest dimension of the video.
// const scanRegionSize = Math.round((2 / 3) * smallestDimension);
// We are going to go larger and use a scan region of 90% of the smallest dimension of the video.
const scanRegionSize = Math.round(smallestDimension * 0.9);
const legacyCanvasSize = 400;
return {
x: Math.round((video.videoWidth - scanRegionSize) / 2),
y: Math.round((video.videoHeight - scanRegionSize) / 2),
width: scanRegionSize,
height: scanRegionSize,
downScaledWidth: legacyCanvasSize,
downScaledHeight: legacyCanvasSize,
};
}

/* Init/Destroy to be called on mount/unmount */
useEffect(() => {
if (videoRef.current === null) return;
if (qrScannerRef.current !== null) return;
initQrScanner(videoRef.current);
return () => {
cleanUpQrScanner();
};
}, []);

/* Start/Stop called when isStopped state changes */
useEffect(() => {
if (isStopped) {
qrScannerRef.current?.stop();
} else {
startQrScanner();
}
}, [isStopped]);

useEffect(() => {
if (selectedCamera !== null) {
qrScannerRef.current?.setCamera(selectedCamera.id);
}
}, [selectedCamera]);

function onScanned(qrCodeData: QrScanner.ScanResult) {
const qrCode = qrCodeData.data;
if (qrCode === '' || qrCode === lastQRCode) {
return;
}
setLastQRCode(qrCode);
}

function onScanError(error: Error | string) {
if (error === QrScanner.NO_QR_CODE_FOUND) return;
console.error('Scanning ERROR:', error);
}

/*
Invoke the onQRCodeScanned action only when a new QR Code has been scanned.
The QrScanner component keeps re-calling the 'onScanned' callback above with the same QR code while the QR Code can be scanned.
We don't want to replicate this behaviour, so we maintain the lastQRCode that was scanned and emit an event only when a new QR Code was scanned. */
useEffect(() => {
if (!lastQRCode) return;
onQRCodeScanned(lastQRCode);
}, [onQRCodeScanned, lastQRCode]);

function startOrStop() {
setIsStopped(!isStopped);
}

const possibleCameras = [
{ label: 'Generic', items: GENERIC_CAMERAS },
{ label: 'Specific', items: availableCameras },
];

const videoStyle: React.CSSProperties = {};
if (videoMaxWidthPx) {
videoStyle['maxWidth'] = videoMaxWidthPx;
}

return (
<div className="p-d-flex p-flex-column p-ai-center">
<div className="p-mb-3">
{availableCameras && (
<Dropdown
placeholder="Select Camera"
options={possibleCameras}
optionLabel="label"
optionGroupLabel="label"
optionGroupChildren="items"
value={selectedCamera}
onChange={(e) => setSelectedCamera(e.value)}
/>
)}
</div>
<video className="p-shadow-8" style={videoStyle} width="100%" height="auto" ref={videoRef}></video>
<div className="p-d-inline-flex p-mt-3">
<ToggleButton
className="p-mr-2"
onLabel="Start"
offLabel="Stop"
onIcon="pi pi-play"
offIcon="pi pi-stop"
checked={isStopped}
onChange={startOrStop}
></ToggleButton>
</div>
</div>
);
};

export default ReactQRScanner;
23 changes: 23 additions & 0 deletions packages/form-components/src/controls/QRCode/QRScannerControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { and, ControlProps, ControlState, optionIs, RankedTester, rankWith, uiTypeIs } from '@jsonforms/core';
import { Control, withJsonFormsControlProps } from '@jsonforms/react';

import { ReactQRScanner } from '../../component/QRCode/ReactQRScanner';

export class QRScannerControl extends Control<ControlProps, ControlState> {
render() {
const { data } = this.props;
const { fps, autoStartScanning } = data;

return (
<div className="p-field">
<ReactQRScanner autoStartScanning={autoStartScanning} videoMaxWidthPx={640} />
</div>
);
}
}

export const isQRScannerControl = and(uiTypeIs('Control'), optionIs('type', 'qr-scanner'));

export const qrScannerControlTester: RankedTester = rankWith(2, isQRScannerControl);
export default withJsonFormsControlProps(QRScannerControl);
3 changes: 3 additions & 0 deletions packages/form-components/src/controls/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import InputControl, { inputControlTester } from './InputControl';
import InputBooleanControl, { inputBooleanControlTester } from './InputBooleanControl';
import SvgMapControl, { svgMapControlTester } from './SvgMapControl';
import QRScannerControl, { qrScannerControlTester } from './QRCode/QRScannerControl';
import SampleContainerControl, { sampleContainerControlTester } from './Biobank/SampleContainerControl';

export {
Expand All @@ -12,4 +13,6 @@ export {
svgMapControlTester,
SampleContainerControl,
sampleContainerControlTester,
QRScannerControl,
qrScannerControlTester,
};
4 changes: 3 additions & 1 deletion packages/form-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
svgMapControlTester,
SampleContainerControl,
sampleContainerControlTester,
QRScannerControl,
qrScannerControlTester,
} from './controls';
import {
CategorizationLayout,
Expand Down Expand Up @@ -54,7 +56,6 @@ import {
InputTextCell,
inputTextCellTester,
} from './cells';
import { sample } from 'lodash';

export * from './controls';
export * from './cells';
Expand All @@ -66,6 +67,7 @@ export const renderers: { tester: RankedTester; renderer: any }[] = [
{ tester: inputBooleanControlTester, renderer: InputBooleanControl },
{ tester: svgMapControlTester, renderer: SvgMapControl },
{ tester: sampleContainerControlTester, renderer: SampleContainerControl },
{ tester: qrScannerControlTester, renderer: QRScannerControl },
{ tester: verticalLayoutTester, renderer: VerticalLayout },
{ tester: categorizationLayoutTester, renderer: CategorizationLayout },
{ tester: categoryLayoutTester, renderer: CategoryLayout },
Expand Down
27 changes: 18 additions & 9 deletions packages/form-definition/src/interfaces/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum InputType {
MULTILINE_TEXT = 'multiline-text',
NUMERIC = 'numeric',
OPTIONS = 'options',
QR_SCANNER = 'qr-scanner',
RANGE = 'range',
SAMPLE_CONTAINER = 'biobank-sample-container',
SIGNATURE = 'signature',
Expand Down Expand Up @@ -103,15 +104,14 @@ export interface OptionsInput extends AbstractInput {
/**
* Option values and labels
*/
options:
{
/**
* @format uuid
*/
id: string;
label: string;
value: number | string;
}[];
options: {
/**
* @format uuid
*/
id: string;
label: string;
value: number | string;
}[];
}

/**
Expand Down Expand Up @@ -289,6 +289,14 @@ export interface CurrencyInput extends AbstractInput {
maximum?: number;
}

/**
* @title QR Scanner
*
*/
export interface QRScannerInput extends AbstractInput {
type: InputType.QR_SCANNER;
}

/**
* @title Sample Container
*
Expand Down Expand Up @@ -331,6 +339,7 @@ export type Input =
| MultilineTextInput
| NumericInput
| OptionsInput
| QRScannerInput
| RangeInput
| Signature
| SampleContainerInput
Expand Down
Loading