[IMP] web: add "crop" overlay in barcode scanner

The commit restores a similar crop overlay like in the Odoo Android
Mobile App for the Barcode scanner.

This feature helps to scan the right barcode when the user scans a
barcode surrounded by other barcode, which before this commit a barcode
was scanned but in some cases not the good one. The commit solves the
problem by returning only the values into the rectangle overlay.

Part-of: odoo/odoo#112855
This commit is contained in:
Romeo Fragomeli
2023-02-17 12:07:20 +01:00
parent 5d211327c1
commit f9fd098f18
9 changed files with 404 additions and 13 deletions
+2
View File
@@ -592,6 +592,8 @@ This module provides the core of the Odoo Web Client.
'web/static/lib/fullcalendar/list/main.js',
'web/static/lib/fullcalendar/luxon/main.js',
'web/static/lib/zxing-library/zxing-library.js',
'web/static/lib/ace/ace.js',
'web/static/lib/ace/javascript_highlight_rules.js',
'web/static/lib/ace/mode-python.js',
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<path d="M 0 15 a 15 15 0 1 1 30 0 a 15 15 0 1 1 -30 0" fill="white"/>
<path d="m25,21l0,-2l-14,0l0,-12l2,0l-3,-3l-3,3l2,0l0,2l-4,0l0,2l4,0l0,8c0,1.1 0.9,2 2,2l8,0l0,2l-2,0l3,3l3,-3l-2,0l0,-2l4,0zm-12,-10l6,0l0,6l2,0l0,-6c0,-1.1 -0.9,-2 -2,-2l-6,0l0,2z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

@@ -6,14 +6,24 @@ import Dialog from "web.OwlDialog";
import { delay } from "web.concurrency";
import { loadJS, templates } from "@web/core/assets";
import { isVideoElementReady, buildZXingBarcodeDetector } from "./ZXingBarcodeDetector";
import { CropOverlay } from "./crop_overlay";
import { App, Component, EventBus, onMounted, onWillStart, onWillUnmount, useRef } from "@odoo/owl";
import { _t } from "web.core";
import {
App,
Component,
EventBus,
onMounted,
onWillStart,
onWillUnmount,
useRef,
useState,
} from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
const bus = new EventBus();
const busOk = "BarcodeDialog-Ok";
const busError = "BarcodeDialog-Error";
class BarcodeDialog extends Component {
export class BarcodeDialog extends Component {
/**
* @override
*/
@@ -22,6 +32,11 @@ class BarcodeDialog extends Component {
this.interval = null;
this.stream = null;
this.detector = null;
this.overlayInfo = {};
this.zoomRatio = 1;
this.state = useState({
isReady: false,
});
onWillStart(async () => {
let DetectorClass;
@@ -58,6 +73,15 @@ class BarcodeDialog extends Component {
}
this.videoPreviewRef.el.srcObject = this.stream;
await this.isVideoReady();
const { height, width } = getComputedStyle(this.videoPreviewRef.el);
const divWidth = width.slice(0, -2);
const divHeight = height.slice(0, -2);
const tracks = this.stream.getVideoTracks();
if (tracks.length) {
const [track] = tracks;
const settings = track.getSettings();
this.zoomRatio = Math.min(divWidth / settings.width, divHeight / settings.height);
}
this.interval = setInterval(this.detectCode.bind(this), 100);
});
@@ -82,10 +106,15 @@ class BarcodeDialog extends Component {
while (!isVideoElementReady(this.videoPreviewRef.el)) {
await delay(10);
}
this.state.isReady = true;
resolve();
});
}
onResize(overlayInfo) {
this.overlayInfo = overlayInfo;
}
/**
* Detection success handler
*
@@ -112,10 +141,25 @@ class BarcodeDialog extends Component {
async detectCode() {
try {
const codes = await this.detector.detect(this.videoPreviewRef.el);
if (codes.length === 0) {
return;
for (const code of codes) {
let { x, y, width, height } = code.boundingBox;
x *= this.zoomRatio;
y *= this.zoomRatio;
width *= this.zoomRatio;
height *= this.zoomRatio;
if (this.overlayInfo.x && this.overlayInfo.y) {
if (
x < this.overlayInfo.x ||
x + width > this.overlayInfo.x + this.overlayInfo.width ||
y < this.overlayInfo.y ||
y + height > this.overlayInfo.y + this.overlayInfo.height
) {
continue;
}
}
this.onResult(code.rawValue);
break;
}
this.onResult(codes[0].rawValue);
} catch (err) {
this.onError(err);
}
@@ -125,6 +169,7 @@ class BarcodeDialog extends Component {
Object.assign(BarcodeDialog, {
components: {
Dialog,
CropOverlay,
},
template: "web.BarcodeDialog",
});
@@ -0,0 +1,6 @@
.modal .o-barcode-modal .modal-body {
overflow: hidden;
@include media-breakpoint-down(md) {
padding: 0;
}
}
@@ -1,13 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BarcodeDialog" owl="1">
<Dialog title="'Barcode Scanner'" onClosed="props.onClose" fullscreen="true">
<div class="p-0 d-flex justify-content-center flex-column">
<video t-ref="videoPreview" autoplay="true" playsinline="true" class="" style="max-width: 100%; max-height: 100%;"/>
</div>
<t t-set-slot="buttons">
<button class="btn btn-secondary" t-on-click="props.onClose">Cancel</button>
</t>
<Dialog title="'Barcode Scanner'" onClosed="props.onClose" fullscreen="true" renderFooter="false" contentClass="'o-barcode-modal'">
<CropOverlay onResize.bind="this.onResize" isReady="state.isReady">
<video t-ref="videoPreview" muted="true" autoplay="true" playsinline="true" class="mw-100 mh-100"/>
</CropOverlay>
</Dialog>
</t>
</templates>
@@ -0,0 +1,155 @@
/** @odoo-module **/
import { Component, useRef, onPatched } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { clamp } from "@web/core/utils/numbers";
export class CropOverlay extends Component {
setup() {
this.localStorageKey = "o-barcode-scanner-overlay";
this.cropContainerRef = useRef("crop-container");
this.isMoving = false;
this.boundaryOverlay = {};
this.relativePosition = {
x: 0,
y: 0,
};
onPatched(() => {
this.setupCropRect();
});
}
setupCropRect() {
if (!this.props.isReady) {
return;
}
this.computeDefaultPoint();
this.computeOverlayPosition();
this.calculateAndSetTransparentRect();
this.executeOnResizeCallback();
}
boundPoint(pointValue, boundaryRect) {
return {
x: clamp(pointValue.x, boundaryRect.left, boundaryRect.left + boundaryRect.width),
y: clamp(pointValue.y, boundaryRect.top, boundaryRect.top + boundaryRect.height),
};
}
calculateAndSetTransparentRect() {
const cropTransparentRect = this.getTransparentRec(
this.relativePosition,
this.boundaryOverlay
);
this.setCropValue(cropTransparentRect, this.relativePosition);
}
computeOverlayPosition() {
const cropOverlayElement = this.cropContainerRef.el.querySelector(".o_crop_overlay");
this.boundaryOverlay = cropOverlayElement.getBoundingClientRect();
}
executeOnResizeCallback() {
const transparentRec = this.getTransparentRec(this.relativePosition, this.boundaryOverlay);
browser.localStorage.setItem(this.localStorageKey, JSON.stringify(transparentRec));
this.props.onResize({
...transparentRec,
width: this.boundaryOverlay.width - 2 * transparentRec.x,
height: this.boundaryOverlay.height - 2 * transparentRec.y,
});
}
computeDefaultPoint() {
const firstChildComputedStyle = getComputedStyle(this.cropContainerRef.el.firstChild);
const elementWidth = firstChildComputedStyle.width.slice(0, -2);
const elementHeight = firstChildComputedStyle.height.slice(0, -2);
const stringSavedPoint = browser.localStorage.getItem(this.localStorageKey);
if (stringSavedPoint) {
const savedPoint = JSON.parse(stringSavedPoint);
this.relativePosition = {
x: clamp(savedPoint.x, 0, elementWidth),
y: clamp(savedPoint.y, 0, elementHeight),
};
} else {
const stepWidth = elementWidth / 10;
const width = stepWidth * 8;
const height = width / 4;
const startY = elementHeight / 2 - height / 2;
this.relativePosition = {
x: stepWidth + width,
y: startY + height,
};
}
}
getTransparentRec(point, rect) {
const middleX = rect.width / 2;
const middleY = rect.height / 2;
const newDeltaX = Math.abs(point.x - middleX);
const newDeltaY = Math.abs(point.y - middleY);
return {
x: middleX - newDeltaX,
y: middleY - newDeltaY,
};
}
setCropValue(point, iconPoint) {
if (!iconPoint) {
iconPoint = point;
}
this.cropContainerRef.el.style.setProperty("--o-crop-x", `${point.x}px`);
this.cropContainerRef.el.style.setProperty("--o-crop-y", `${point.y}px`);
this.cropContainerRef.el.style.setProperty("--o-crop-icon-x", `${iconPoint.x}px`);
this.cropContainerRef.el.style.setProperty("--o-crop-icon-y", `${iconPoint.y}px`);
}
pointerDown(event) {
event.preventDefault();
if (event.target.matches(".o_crop_icon")) {
this.computeOverlayPosition();
this.isMoving = true;
}
}
pointerMove(event) {
if (!this.isMoving) {
return;
}
let eventPosition;
if (event.touches && event.touches.length) {
eventPosition = event.touches[0];
} else {
eventPosition = event;
}
const { clientX, clientY } = eventPosition;
const restrictedPosition = this.boundPoint(
{
x: clientX,
y: clientY,
},
this.boundaryOverlay
);
this.relativePosition = {
x: restrictedPosition.x - this.boundaryOverlay.left,
y: restrictedPosition.y - this.boundaryOverlay.top,
};
this.calculateAndSetTransparentRect(this.relativePosition);
}
pointerUp(event) {
this.isMoving = false;
this.executeOnResizeCallback();
}
}
CropOverlay.template = "web.CropOverlay";
CropOverlay.props = {
onResize: Function,
isReady: Boolean,
slots: {
type: Object,
shape: {
default: {},
},
},
};
@@ -0,0 +1,32 @@
.o_crop_container {
position: relative;
> * {
grid-row: 1 / -1;
grid-column: 1 / -1;
}
.o_crop_overlay {
background-color: RGB(0 0 0 / 0.75);
mix-blend-mode: darken;
&::after {
content: '';
display: block;
height: 100%;
width: 100%;
clip-path: inset(var(--o-crop-y, 0px) var(--o-crop-x, 0px));
background-color: white;
}
}
.o_crop_icon {
--o-crop-icon-width: 20px;
--o-crop-icon-height: 20px;
position: absolute;
width: var(--o-crop-icon-width);
height: var(--o-crop-icon-height);
left: calc(var(--o-crop-icon-x, 0px) - (var(--o-crop-icon-width) / 2));
top: calc(var(--o-crop-icon-y, 0px) - (var(--o-crop-icon-height) / 2));
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CropOverlay" owl="1">
<div t-ref="crop-container"
t-on-mousedown="pointerDown" t-on-touchstart="pointerDown"
t-on-mousemove="pointerMove" t-on-touchmove="pointerMove"
t-on-mouseup="pointerUp" t-on-touchend="pointerUp"
class="d-grid align-content-center justify-content-center h-100 o_crop_container"
>
<t t-slot="default"/>
<t t-if="props.isReady">
<div class="o_crop_overlay"/>
<img class="o_crop_icon" src="/web/static/img/transform.svg" draggable="false"/>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,133 @@
/** @odoo-module **/
/* global ZXing */
import { browser } from "@web/core/browser/browser";
import {
makeDeferred,
nextTick,
patchWithCleanup,
triggerEvent,
} from "@web/../tests/helpers/utils";
import { scanBarcode, BarcodeDialog } from "@web/webclient/barcode/barcode_scanner";
QUnit.module("Barcode scanner", {});
QUnit.test("Barcode scanner crop overlay", async (assert) => {
const firstBarcodeValue = "Odoo";
const secondBarcodeValue = "O-CMD-TEST";
let barcodeToGenerate = firstBarcodeValue;
let videoReady = makeDeferred();
function mockUserMedia() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const stream = canvas.captureStream();
const multiFormatWriter = new ZXing.MultiFormatWriter();
const bitMatrix = multiFormatWriter.encode(
barcodeToGenerate,
ZXing.BarcodeFormat.QR_CODE,
250,
250,
null
);
canvas.width = bitMatrix.width;
canvas.height = bitMatrix.height;
for (let x = 0; x < bitMatrix.width; x++) {
for (let y = 0; y < bitMatrix.height; y++) {
if (bitMatrix.get(x, y)) {
ctx.beginPath();
ctx.rect(x, y, 1, 1);
ctx.stroke();
}
}
}
return stream;
}
// simulate an environment with a camera/webcam
patchWithCleanup(
browser,
Object.assign({}, browser, {
navigator: {
mediaDevices: {
getUserMedia: mockUserMedia,
},
},
})
);
patchWithCleanup(BarcodeDialog.prototype, {
async isVideoReady() {
return this._super(...arguments).then(() => {
videoReady.resolve();
});
},
onResize(overlayInfo) {
assert.step(JSON.stringify(overlayInfo));
return this._super(...arguments);
},
});
const firstBarcodeFound = scanBarcode();
await videoReady;
// Needed due to the change on the props in the Crop component
await nextTick();
const cropIconSelector = ".o_crop_icon";
const cropIcon = document.querySelector(cropIconSelector);
const cropOverlay = document.querySelector(".o_crop_overlay");
const cropContainer = document.querySelector(".o_crop_container");
const cropIconPosition = cropIcon.getBoundingClientRect();
const cropOverlayPosition = cropOverlay.getBoundingClientRect();
await triggerEvent(cropContainer, cropIconSelector, "touchstart", {
touches: [
{
identifier: 0,
clientX: cropIconPosition.x + cropIconPosition.width / 2,
clientY: cropIconPosition.y + cropIconPosition.height / 2,
target: cropIcon,
},
],
});
await triggerEvent(cropContainer, cropIconSelector, "touchmove", {
touches: [
{
identifier: 0,
clientX: cropOverlayPosition.right,
clientY: cropOverlayPosition.bottom,
target: cropIcon,
},
],
});
await triggerEvent(cropContainer, cropIconSelector, "touchend", {});
const firstValueScanned = await firstBarcodeFound;
assert.strictEqual(
firstValueScanned,
firstBarcodeValue,
`The detected barcode should be the same as generated (${firstBarcodeValue})`
);
// Do another scan barcode to the test position of the overlay saved in the locale storage
// Reset all values for the second test
barcodeToGenerate = secondBarcodeValue;
videoReady = makeDeferred();
const secondBarcodeFound = scanBarcode();
await videoReady;
const secondValueScanned = await secondBarcodeFound;
assert.strictEqual(
secondValueScanned,
secondBarcodeValue,
`The detected barcode should be the same as generated (${secondBarcodeValue})`
);
assert.verifySteps(
[
JSON.stringify({ x: 25, y: 100, width: 200, height: 50 }),
JSON.stringify({ x: 0, y: 0, width: 250, height: 250 }),
JSON.stringify({ x: 0, y: 0, width: 250, height: 250 }),
],
"We should haves three resize event; one for the default position, another one for the all frame and the last one must be the same as the saved second position"
);
});