diff --git a/public/barcode-reader/barcode-reader.css b/public/barcode-reader/barcode-reader.css new file mode 100644 index 0000000..21b2229 --- /dev/null +++ b/public/barcode-reader/barcode-reader.css @@ -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; +} \ No newline at end of file diff --git a/src/app/types/globalThis.d.ts b/src/app/types/globalThis.d.ts new file mode 100644 index 0000000..2823421 --- /dev/null +++ b/src/app/types/globalThis.d.ts @@ -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; + detect(imageBitmapSource: ImageBitmapSource): Promise; +} \ No newline at end of file diff --git a/src/app/web-components/barcode-reader.ts b/src/app/web-components/barcode-reader.ts new file mode 100644 index 0000000..abc676a --- /dev/null +++ b/src/app/web-components/barcode-reader.ts @@ -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 { + 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'; + } +} diff --git a/src/main.ts b/src/main.ts index 5df75f9..28afe9e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,8 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; +import { BarcodeReader } from './app/web-components/barcode-reader'; bootstrapApplication(App, appConfig) + .then(() => customElements.define('barcode-reader', BarcodeReader)) .catch((err) => console.error(err));