From f9fd098f1876213df0e1899795e0cd2b6c06c793 Mon Sep 17 00:00:00 2001 From: Romeo Fragomeli Date: Fri, 27 Jan 2023 12:25:24 +0000 Subject: [PATCH] [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 --- addons/web/__manifest__.py | 2 + addons/web/static/img/transform.svg | 4 + .../src/webclient/barcode/barcode_scanner.js | 57 ++++++- .../webclient/barcode/barcode_scanner.scss | 6 + .../src/webclient/barcode/barcode_scanner.xml | 11 +- .../src/webclient/barcode/crop_overlay.js | 155 ++++++++++++++++++ .../src/webclient/barcode/crop_overlay.scss | 32 ++++ .../src/webclient/barcode/crop_overlay.xml | 17 ++ .../barcode/barcode_scanner_tests.js | 133 +++++++++++++++ 9 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 addons/web/static/img/transform.svg create mode 100644 addons/web/static/src/webclient/barcode/barcode_scanner.scss create mode 100644 addons/web/static/src/webclient/barcode/crop_overlay.js create mode 100644 addons/web/static/src/webclient/barcode/crop_overlay.scss create mode 100644 addons/web/static/src/webclient/barcode/crop_overlay.xml create mode 100644 addons/web/static/tests/webclient/barcode/barcode_scanner_tests.js diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index e5afe5e6654..2c904b6bd92 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -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', diff --git a/addons/web/static/img/transform.svg b/addons/web/static/img/transform.svg new file mode 100644 index 00000000000..a39c6d8a10c --- /dev/null +++ b/addons/web/static/img/transform.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/web/static/src/webclient/barcode/barcode_scanner.js b/addons/web/static/src/webclient/barcode/barcode_scanner.js index f4eb4e01d7c..8da814e966e 100644 --- a/addons/web/static/src/webclient/barcode/barcode_scanner.js +++ b/addons/web/static/src/webclient/barcode/barcode_scanner.js @@ -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", }); diff --git a/addons/web/static/src/webclient/barcode/barcode_scanner.scss b/addons/web/static/src/webclient/barcode/barcode_scanner.scss new file mode 100644 index 00000000000..0f498292ec3 --- /dev/null +++ b/addons/web/static/src/webclient/barcode/barcode_scanner.scss @@ -0,0 +1,6 @@ +.modal .o-barcode-modal .modal-body { + overflow: hidden; + @include media-breakpoint-down(md) { + padding: 0; + } +} diff --git a/addons/web/static/src/webclient/barcode/barcode_scanner.xml b/addons/web/static/src/webclient/barcode/barcode_scanner.xml index fb004627c02..2de6df69a9a 100644 --- a/addons/web/static/src/webclient/barcode/barcode_scanner.xml +++ b/addons/web/static/src/webclient/barcode/barcode_scanner.xml @@ -1,13 +1,10 @@ - -
-
- - - + + +
diff --git a/addons/web/static/src/webclient/barcode/crop_overlay.js b/addons/web/static/src/webclient/barcode/crop_overlay.js new file mode 100644 index 00000000000..7b38ac4e162 --- /dev/null +++ b/addons/web/static/src/webclient/barcode/crop_overlay.js @@ -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: {}, + }, + }, +}; diff --git a/addons/web/static/src/webclient/barcode/crop_overlay.scss b/addons/web/static/src/webclient/barcode/crop_overlay.scss new file mode 100644 index 00000000000..132ba6e7a73 --- /dev/null +++ b/addons/web/static/src/webclient/barcode/crop_overlay.scss @@ -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)); + } +} diff --git a/addons/web/static/src/webclient/barcode/crop_overlay.xml b/addons/web/static/src/webclient/barcode/crop_overlay.xml new file mode 100644 index 00000000000..60001068eef --- /dev/null +++ b/addons/web/static/src/webclient/barcode/crop_overlay.xml @@ -0,0 +1,17 @@ + + + +
+ + +
+ + +
+
+ diff --git a/addons/web/static/tests/webclient/barcode/barcode_scanner_tests.js b/addons/web/static/tests/webclient/barcode/barcode_scanner_tests.js new file mode 100644 index 00000000000..2bf838e1dc7 --- /dev/null +++ b/addons/web/static/tests/webclient/barcode/barcode_scanner_tests.js @@ -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" + ); +});