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"
+ );
+});