feat: add barcode reader
This commit is contained in:
111
public/barcode-reader/barcode-reader.css
Normal file
111
public/barcode-reader/barcode-reader.css
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
--video-width: 100%;
|
||||||
|
--video-height: 100dvh;
|
||||||
|
--video-background-color: #cccccc;
|
||||||
|
--dialog-background-color: #ffffff;
|
||||||
|
--dialog-backdrop-background-color: #cecece;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
width: var(--video-width);
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1024px) {
|
||||||
|
#container {
|
||||||
|
min-width: 400px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: var(--video-height);
|
||||||
|
max-height: var(--video-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 30%;
|
||||||
|
width: 100%;
|
||||||
|
height: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#data-canvas {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#frame {
|
||||||
|
opacity: 1;
|
||||||
|
position: absolute;
|
||||||
|
top: 30%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 25%;
|
||||||
|
background-color: transparent;
|
||||||
|
transform-origin: center center;
|
||||||
|
transform: scale(0.9);
|
||||||
|
transition: transform .3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([capturing]) #frame {
|
||||||
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([no-frame]) #frame {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner {
|
||||||
|
position: absolute;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 3px solid #ee0b0b;
|
||||||
|
clip-path: polygon(0 48px, 48px 48px, 48px 0, 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-left {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-right {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-right {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-left {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 31%;
|
||||||
|
left: 2%;
|
||||||
|
width: 96%;
|
||||||
|
height: 23%;
|
||||||
|
border-radius: 2%;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 0 0 200vmax rgba(0, 0, 0, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
22
src/app/types/globalThis.d.ts
vendored
Normal file
22
src/app/types/globalThis.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
type ImageBitmapSource = HTMLImageElement | SVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas | VideoFrame | Blob | ImageData
|
||||||
|
|
||||||
|
export type BarcodeDetectorOptions = {
|
||||||
|
formats: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetectedBarcode = {
|
||||||
|
boundingBox: DOMRectReadOnly;
|
||||||
|
cornerPoints: coords[];
|
||||||
|
format: string[];
|
||||||
|
rawValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var BarcodeDetector: BarcodeDetector;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare type BarcodeDetector = {
|
||||||
|
new (formats?: {formats: string[]}): BarcodeDetector;
|
||||||
|
static getSupportedFormats(): Promise<string[]>;
|
||||||
|
detect(imageBitmapSource: ImageBitmapSource): Promise<DetectedBarcode[]>;
|
||||||
|
}
|
||||||
192
src/app/web-components/barcode-reader.ts
Normal file
192
src/app/web-components/barcode-reader.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type { BarcodeDetector, DetectedBarcode } from '../types/globalThis';
|
||||||
|
|
||||||
|
export class BarcodeReader extends HTMLElement {
|
||||||
|
animationFrameId = null;
|
||||||
|
constraints = {
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
container = document.createElement('div');
|
||||||
|
video = document.createElement('video');
|
||||||
|
dataCanvas = document.createElement('canvas');
|
||||||
|
dataCtx: CanvasRenderingContext2D | null | undefined;
|
||||||
|
detector: BarcodeDetector | null = null;
|
||||||
|
code: DetectedBarcode | null = null;
|
||||||
|
stream: MediaStream | null = null;
|
||||||
|
scanId = -1;
|
||||||
|
frame = document.createElement('div');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = '/barcode-reader/barcode-reader.css';
|
||||||
|
this.container.setAttribute('id', 'container');
|
||||||
|
const videoContainer = document.createElement('div');
|
||||||
|
videoContainer.setAttribute('id', 'video-container');
|
||||||
|
this.container.appendChild(videoContainer);
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.classList.add('overlay');
|
||||||
|
this.video.setAttribute('autoplay', '');
|
||||||
|
this.video.setAttribute('playsinline', '');
|
||||||
|
this.dataCanvas.setAttribute('id', 'data-canvas');
|
||||||
|
|
||||||
|
this.frame.setAttribute('id', 'frame');
|
||||||
|
this.frame.style.display = 'none';
|
||||||
|
for (let el of ['top-left', 'top-right', 'bottom-right', 'bottom-left']) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.classList.add('corner', el);
|
||||||
|
this.frame.appendChild(div);
|
||||||
|
}
|
||||||
|
videoContainer.appendChild(overlay);
|
||||||
|
videoContainer.appendChild(this.dataCanvas);
|
||||||
|
videoContainer.appendChild(this.video);
|
||||||
|
videoContainer.appendChild(this.frame);
|
||||||
|
|
||||||
|
const shadowRoot = this.attachShadow({ mode: 'open' });
|
||||||
|
shadowRoot.appendChild(link);
|
||||||
|
shadowRoot.appendChild(this.container);
|
||||||
|
this.capturing = false;
|
||||||
|
|
||||||
|
this.dataCtx = this.dataCanvas.getContext('2d', { willReadFrequently: true, alpha: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
get capturing() {
|
||||||
|
return this.hasAttribute('capturing');
|
||||||
|
}
|
||||||
|
|
||||||
|
set capturing(isCapturing) {
|
||||||
|
if (isCapturing) {
|
||||||
|
this.setAttribute('capturing', '');
|
||||||
|
} else {
|
||||||
|
this.removeAttribute('capturing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
this.detector = await this.getDetector();
|
||||||
|
this.video.onplaying = async () => {
|
||||||
|
if (this.dataCanvas) {
|
||||||
|
this.dataCanvas.width = this.dataCanvas.offsetWidth;
|
||||||
|
this.dataCanvas.height = this.dataCanvas.offsetHeight;
|
||||||
|
this.getResults();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDetector(): Promise<BarcodeDetector | null> {
|
||||||
|
if (globalThis.BarcodeDetector) {
|
||||||
|
const formats = await BarcodeDetector.getSupportedFormats();
|
||||||
|
return new BarcodeDetector({ formats });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan() {
|
||||||
|
this.showContainer(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.capturing = true;
|
||||||
|
this.code = null;
|
||||||
|
|
||||||
|
if (this.stream instanceof MediaStream) {
|
||||||
|
await this.getResults();
|
||||||
|
} else {
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'loading' } }));
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia(this.constraints);
|
||||||
|
this.startVideo();
|
||||||
|
this.showFrame();
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'started' } }));
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-start'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
this.showContainer(false);
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'stopped' } }));
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-permission-denied'));
|
||||||
|
this.capturing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopScan() {
|
||||||
|
clearTimeout(this.scanId);
|
||||||
|
if (this.stream) {
|
||||||
|
this.capturing = false;
|
||||||
|
this.stream.getTracks().forEach((track) => track.stop());
|
||||||
|
this.stream = null;
|
||||||
|
this.video.srcObject = null;
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-stop'));
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'stopped' } }));
|
||||||
|
this.hideFrame();
|
||||||
|
this.showContainer(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startVideo() {
|
||||||
|
if (this.video) this.video.srcObject = this.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResults(results: DetectedBarcode[]) {
|
||||||
|
const code = results.length > 0 ? results[0] : undefined;
|
||||||
|
if (code && code.rawValue !== '') {
|
||||||
|
clearTimeout(this.scanId);
|
||||||
|
this.code = code;
|
||||||
|
this.capturing = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('result', {
|
||||||
|
detail: { code },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.stopScan();
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
this.scanId = setTimeout(this.getResults.bind(this), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResults() {
|
||||||
|
if (this.code) {
|
||||||
|
clearTimeout(this.scanId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.video && this.detector) {
|
||||||
|
let imgData: ImageData | null = this.getImageData(this.video);
|
||||||
|
if (imgData) {
|
||||||
|
const results = await this.detector.detect(imgData);
|
||||||
|
imgData = null;
|
||||||
|
this.parseResults(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('error', e);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageData(source: HTMLVideoElement) {
|
||||||
|
if (this.dataCtx) {
|
||||||
|
this.dataCtx.drawImage(source, 0, 0, this.dataCanvas.width, this.dataCanvas.height);
|
||||||
|
return this.dataCtx.getImageData(0, 0, this.dataCanvas.width, this.dataCanvas.height);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
showContainer(show: boolean) {
|
||||||
|
this.container.style.display = show ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
showFrame() {
|
||||||
|
this.frame.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
hideFrame() {
|
||||||
|
this.frame.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
import { BarcodeReader } from './app/web-components/barcode-reader';
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig)
|
bootstrapApplication(App, appConfig)
|
||||||
|
.then(() => customElements.define('barcode-reader', BarcodeReader))
|
||||||
.catch((err) => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
|
|||||||
Reference in New Issue
Block a user