react-qrcode-scan
Version:
React componnent write by typescript for scan qrcode base on instascan.js
437 lines (369 loc) • 10.7 kB
text/typescript
/**
* Core Code come from https://github.com/schmich/instascan
* Now I just rewrite again by typescript
* ZXing ("zebra crossing") is an open-source, multi-format 1D/2D barcode image processing library implemented in Java, with ports to other languages.
* You can get everything here https://github.com/zxing/zxing
*/
import { EventEmitter } from "events";
import * as Visibility from "visibilityjs";
const StateMachine = require("fsm-as-promised");
const ZXing = require("./zxing")();
class ScanProvider {
_active: boolean;
scanPeriod: number;
captureImage: boolean;
refractoryPeriod: number;
_emitter: Scanner;
_frameCount: number;
_analyzer: Analyzer;
_lastResult: any;
refractoryTimeout: NodeJS.Timeout;
constructor(
emitter: Scanner,
analyzer: Analyzer,
captureImage: boolean,
scanPeriod: number,
refractoryPeriod: number
) {
this.scanPeriod = scanPeriod;
this.captureImage = captureImage;
this.refractoryPeriod = refractoryPeriod;
this._emitter = emitter;
this._frameCount = 0;
this._analyzer = analyzer;
this._lastResult = null;
this._active = false;
}
start() {
this._active = true;
requestAnimationFrame(() => this._scan());
}
stop() {
this._active = false;
}
scan() {
return this._analyze(false);
}
_analyze(skipDups: boolean): { content: string; image?: string } | null {
let analysis = this._analyzer.analyze();
if (!analysis) {
return null;
}
let { result, canvas } = analysis;
if (!result) {
return null;
}
if (skipDups && result === this._lastResult) {
return null;
}
clearTimeout(this.refractoryTimeout);
this.refractoryTimeout = setTimeout(() => {
this._lastResult = null;
}, this.refractoryPeriod);
let image = this.captureImage ? canvas.toDataURL("image/webp", 0.8) : null;
this._lastResult = result;
let payload: { content: string; image?: string } = { content: result };
if (image) {
payload.image = image;
}
return payload;
}
_scan() {
if (!this._active) {
return;
}
requestAnimationFrame(() => this._scan());
if (++this._frameCount !== this.scanPeriod) {
return;
} else {
this._frameCount = 0;
}
let result = this._analyze(true);
if (result) {
setTimeout(() => {
this._emitter.emit("scan", result?.content, result?.image || null);
}, 0);
}
}
}
class Analyzer {
video: HTMLVideoElement;
sensorLeft: number;
sensorTop: number;
sensorWidth: number;
sensorHeight: number;
canvas: HTMLCanvasElement;
canvasContext: CanvasRenderingContext2D;
decodeCallback: any;
imageBuffer: any;
constructor(video: HTMLVideoElement) {
this.video = video;
this.imageBuffer = null;
this.canvas = document.createElement("canvas");
this.canvas.style.display = "none";
this.decodeCallback = ZXing.Runtime.addFunction(function (
ptr: any,
len: number,
resultIndex: number
) {
let result = new Uint8Array(ZXing.HEAPU8.buffer, ptr, len);
let str = String.fromCharCode.apply(null, result);
if (resultIndex === 0) {
window.zxDecodeResult = "";
}
window.zxDecodeResult += str;
});
}
analyze() {
if (!this.video.videoWidth) {
return null;
}
if (!this.imageBuffer) {
let videoWidth = this.video.videoWidth;
let videoHeight = this.video.videoHeight;
this.sensorWidth = videoWidth;
this.sensorHeight = videoHeight;
this.sensorLeft = Math.floor(videoWidth / 2 - this.sensorWidth / 2);
this.sensorTop = Math.floor(videoHeight / 2 - this.sensorHeight / 2);
this.canvas.width = this.sensorWidth;
this.canvas.height = this.sensorHeight;
this.canvasContext = this.canvas.getContext("2d")!;
this.imageBuffer = ZXing._resize(this.sensorWidth, this.sensorHeight);
return null;
}
this.canvasContext.drawImage(
this.video,
this.sensorLeft,
this.sensorTop,
this.sensorWidth,
this.sensorHeight
);
let data = this.canvasContext.getImageData(0, 0, this.sensorWidth, this.sensorHeight).data;
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
let [r, g, b] = [data[i], data[i + 1], data[i + 2]];
ZXing.HEAPU8[this.imageBuffer + j] = Math.trunc((r + g + b) / 3);
}
let err = ZXing._decode_qr(this.decodeCallback);
if (err) {
return null;
}
let result = window.zxDecodeResult;
if (result != null) {
return { result: result, canvas: this.canvas };
}
return null;
}
}
export interface ScanOptions {
// The HTML element to use for the camera"s video preview. Must be a <video> element.
// When the camera is active, this element will have the "active" CSS class, otherwise,
// it will have the "inactive" class. By default, an invisible element will be created to
// host the video.
video?: HTMLVideoElement;
// Whether to scan continuously for QR codes. If false, use scanner.scan() to manually scan.
// If true, the scanner emits the "scan" event when a QR code is scanned. Default true.
continuous?: boolean;
// Whether to horizontally mirror the video preview. This is helpful when trying to
// scan a QR code with a user-facing camera. Default true.
mirror?: boolean;
// Whether to include the scanned image data as part of the scan result. See the "scan" event
// for image format details. Default false.
captureImage?: boolean;
// Only applies to continuous mode. Whether to actively scan when the tab is not active.
// When false, this reduces CPU usage when the tab is not active. Default true.
backgroundScan?: boolean;
// Only applies to continuous mode. The period, in milliseconds, before the same QR code
// will be recognized in succession. Default 5000 (5 seconds).
refractoryPeriod?: number;
// Only applies to continuous mode. The period, in rendered frames, between scans. A lower scan period
// increases CPU usage but makes scan response faster. Default 1 (i.e. analyze every frame).
scanPeriod?: number;
}
export default class Scanner extends EventEmitter {
video: HTMLVideoElement;
backgroundScan: boolean;
_continuous: boolean;
_analyzer: Analyzer;
_camera: any;
_scanner: ScanProvider;
_fsm: any;
_mirror: any;
constructor(opts: ScanOptions) {
super();
this.video = this._configureVideo(opts);
this.mirror = opts.mirror !== false;
this.backgroundScan = opts.backgroundScan !== false;
this._continuous = opts.continuous !== false;
this._analyzer = new Analyzer(this.video);
this._camera = null;
let captureImage = opts.captureImage || false;
let scanPeriod = opts.scanPeriod || 1;
let refractoryPeriod = opts.refractoryPeriod || 5 * 1000;
this._scanner = new ScanProvider(
this,
this._analyzer,
captureImage,
scanPeriod,
refractoryPeriod
);
this._fsm = this._createStateMachine();
Visibility.change((_: Event, state: string) => {
if (state === "visible") {
setTimeout(() => {
if (this._fsm.can("activate")) {
this._fsm.activate();
}
}, 0);
} else {
if (!this.backgroundScan && this._fsm.can("deactivate")) {
this._fsm.deactivate();
}
}
});
this.addListener("active", () => {
this.video.classList.remove("inactive");
this.video.classList.add("active");
});
this.addListener("inactive", () => {
this.video.classList.remove("active");
this.video.classList.add("inactive");
});
this.emit("inactive");
}
scan() {
return this._scanner.scan();
}
async start(camera = null) {
if (this._fsm.can("start")) {
await this._fsm.start(camera);
} else {
await this._fsm.stop();
await this._fsm.start(camera);
}
return camera;
}
async stop() {
if (this._fsm.can("stop")) {
await this._fsm.stop();
}
}
set captureImage(capture) {
this._scanner.captureImage = capture;
}
get captureImage() {
return this._scanner.captureImage;
}
set scanPeriod(period) {
this._scanner.scanPeriod = period;
}
get scanPeriod() {
return this._scanner.scanPeriod;
}
set refractoryPeriod(period) {
this._scanner.refractoryPeriod = period;
}
get refractoryPeriod() {
return this._scanner.refractoryPeriod;
}
set continuous(continuous) {
this._continuous = continuous;
if (continuous && this._fsm.current === "active") {
this._scanner.start();
} else {
this._scanner.stop();
}
}
get continuous() {
return this._continuous;
}
set mirror(mirror) {
this._mirror = mirror;
if (mirror) {
this.video.style.filter = "FlipH";
this.video.style.transform = "scaleX(-1)";
} else {
this.video.style.filter = "";
this.video.style.transform = "";
}
}
get mirror() {
return this._mirror;
}
async _enableScan(camera: any) {
this._camera = camera || this._camera;
if (!this._camera) {
throw new Error("Camera is not defined.");
}
let stream = await this._camera.start();
this.video.srcObject = stream;
if (this._continuous) {
this._scanner.start();
}
}
_disableScan() {
this.video.src = "";
if (this._scanner) {
this._scanner.stop();
}
if (this._camera) {
this._camera.stop();
}
}
_configureVideo(opts: { video?: HTMLVideoElement }) {
if (opts.video) {
if (opts.video.tagName !== "VIDEO") {
throw new Error("Video must be a <video> element.");
}
}
let video = opts.video || document.createElement("video");
video.setAttribute("autoplay", "autoplay");
return video;
}
_createStateMachine() {
return StateMachine.create({
initial: "stopped",
events: [
{
name: "start",
from: "stopped",
to: "started",
},
{
name: "stop",
from: ["started", "active", "inactive"],
to: "stopped",
},
{
name: "activate",
from: ["started", "inactive"],
to: ["active", "inactive"],
condition: () => {
if (Visibility.state() === "visible" || this.backgroundScan) {
return "active";
} else {
return "inactive";
}
},
},
{
name: "deactivate",
from: ["started", "active"],
to: "inactive",
},
],
callbacks: {
onenteractive: async (options: any) => {
await this._enableScan(options.args[0]);
this.emit("active");
},
onleaveactive: () => {
this._disableScan();
this.emit("inactive");
},
onenteredstarted: async (options: any) => {
await this._fsm.activate(options.args[0]);
},
},
});
}
}