feat: add barcode reader

This commit is contained in:
2026-01-02 23:32:01 -03:00
parent 7c6045f1b3
commit 49b9389532
4 changed files with 327 additions and 0 deletions

View 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
View 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[]>;
}

View 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';
}
}

View File

@@ -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));