[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:
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user