diff --git a/addons/barcodes/static/src/barcode_handlers.js b/addons/barcodes/static/src/barcode_handlers.js new file mode 100644 index 00000000000..84b6ffd77d6 --- /dev/null +++ b/addons/barcodes/static/src/barcode_handlers.js @@ -0,0 +1,30 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { getVisibleElements } from "@web/core/utils/ui"; + +export const barcodeAutoClick = { + dependencies: ["ui", "barcode"], + start(env, { ui, barcode }) { + + barcode.bus.addEventListener("barcode_scanned", (ev) => { + const barcode = ev.detail.barcode; + if (!barcode.startsWith("O-BTN.")) { + return; + } + let targets = []; + try { + // the scanned barcode could be anything, and could crash the queryselectorall + // function + targets = getVisibleElements(ui.activeElement, `[barcode_trigger=${barcode.slice(6)}]`); + } catch (_e) { + console.warn(`Barcode '${barcode}' is not valid`) + } + for (let elem of targets) { + elem.click(); + } + }); + } +}; + +registry.category("services").add("barcode_autoclick", barcodeAutoClick); diff --git a/addons/barcodes/static/src/barcode_service.js b/addons/barcodes/static/src/barcode_service.js new file mode 100644 index 00000000000..354f295b7a9 --- /dev/null +++ b/addons/barcodes/static/src/barcode_service.js @@ -0,0 +1,120 @@ +/** @odoo-module **/ + +import { isBrowserChrome, isMobileOS } from "@web/core/browser/feature_detection"; +import { registry } from "@web/core/registry"; +import { session } from "@web/session"; + +const { EventBus, whenReady } = owl; + +function isEditable(element) { + return element.matches('input,textarea,[contenteditable="true"]'); +} + +function makeBarcodeInput() { + const inputEl = document.createElement('input'); + inputEl.setAttribute("style", "position:fixed;top:50%;transform:translateY(-50%);z-index:-1;opacity:0"); + inputEl.setAttribute("autocomplete", "off"); + inputEl.setAttribute("inputmode", "none"); // magic! prevent native keyboard from popping + inputEl.classList.add("o-barcode-input"); + inputEl.setAttribute('name', 'barcode'); + return inputEl; +} + +export const barcodeService = { + // Keys from a barcode scanner are usually processed as quick as possible, + // but some scanners can use an intercharacter delay (we support <= 50 ms) + maxTimeBetweenKeysInMs: session.max_time_between_keys_in_ms || 55, + + // this is done here to make it easily mockable in mobile tests + isMobileChrome: isMobileOS() && isBrowserChrome(), + + start() { + const bus = new EventBus(); + let timeout = null; + + let bufferedBarcode = ""; + let currentTarget = null; + let barcodeInput = null; + + function handleBarcode(barcode, target) { + bus.trigger('barcode_scanned', {barcode,target}); + if (target.getAttribute('barcode_events') === "true") { + $(target).trigger('barcode_scanned', barcode); + } + } + + /** + * check if we have a barcode, and trigger appropriate events + */ + function checkBarcode() { + const str = barcodeInput ? barcodeInput.value : bufferedBarcode; + if (str.length >= 3) { + handleBarcode(str, currentTarget); + } + if (barcodeInput) { + barcodeInput.value = ""; + } + bufferedBarcode = ""; + currentTarget = null; + } + + function keydownHandler(ev) { + // Ignore 'Shift', 'Escape', 'Backspace', 'Insert', 'Delete', 'Home', 'End', Arrow*, F*, Page*, ... + // ctrl, meta and alt are often used for UX purpose (like shortcuts) + // Note: shiftKey is not ignored because it can be used by some barcode scanner for digits. + const isSpecialKey = ev.key.length > 1 || ev.ctrlKey || ev.metaKey || ev.altKey; + const isEndCharacter = ev.key.match(/(Enter|Tab)/); + + // Don't catch non-printable keys except 'enter' and 'tab' + if (isSpecialKey && !isEndCharacter) { + return; + } + + currentTarget = ev.target; + // Don't catch events targeting elements that are editable because we + // have no way of redispatching 'genuine' key events. Resent events + // don't trigger native event handlers of elements. So this means that + // our fake events will not appear in eg. an element. + if (currentTarget !== barcodeInput && isEditable(currentTarget) && + !currentTarget.dataset.enableBarcode && + currentTarget.getAttribute("barcode_events") !== "true") { + return; + } + + clearTimeout(timeout); + if (isEndCharacter) { + checkBarcode(); + } else { + bufferedBarcode += ev.key; + timeout = setTimeout(checkBarcode, barcodeService.maxTimeBetweenKeysInMs); + } + } + + function mobileChromeHandler(ev) { + if (ev.key === "Unidentified") { + return; + } + if ($(document.activeElement).not('input:text, textarea, [contenteditable], ' + + '[type="email"], [type="number"], [type="password"], [type="tel"], [type="search"]').length) { + barcodeInput.focus(); + } + keydownHandler(ev); + } + + whenReady(() => { + const isMobileChrome = barcodeService.isMobileChrome; + if (isMobileChrome) { + barcodeInput = makeBarcodeInput(); + document.body.appendChild(barcodeInput); + } + const handler = isMobileChrome ? mobileChromeHandler : keydownHandler; + document.body.addEventListener('keydown', handler); + }); + + return { + bus, + }; + }, +}; + +registry.category("services").add("barcode", barcodeService); diff --git a/addons/barcodes/static/src/js/barcode_events.js b/addons/barcodes/static/src/js/barcode_events.js index 6f2f678506c..ee18c54aab7 100644 --- a/addons/barcodes/static/src/js/barcode_events.js +++ b/addons/barcodes/static/src/js/barcode_events.js @@ -1,300 +1,24 @@ -odoo.define('barcodes.BarcodeEvents', function(require) { -"use strict"; +/** @odoo-module **/ -var config = require('web.config'); -var core = require('web.core'); -var mixins = require('web.mixins'); -var session = require('web.session'); +/** + * BarcodeEvents has been removed and replaced by the barcode service. + * + * This file is a temporary service to remap barcode events from new barcode + * service to core.bus (which was the purpose of BarcodeEvents). + * + * @TODO: remove this as soon as all barcode code is using new barcode service + */ +import { registry } from "@web/core/registry"; +import core from "web.core"; -// For IE >= 9, use this, new CustomEvent(), instead of new Event() -function CustomEvent ( event, params ) { - params = params || { bubbles: false, cancelable: false, detail: undefined }; - var evt = document.createEvent( 'CustomEvent' ); - evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail ); - return evt; - } -CustomEvent.prototype = window.Event.prototype; - -var BarcodeEvents = core.Class.extend(mixins.PropertiesMixin, { - timeout: null, - key_pressed: {}, - buffered_key_events: [], - // Regexp to match a barcode input and extract its payload - // Note: to build in init() if prefix/suffix can be configured - regexp: /(.{3,})[\n\r\t]*/, - // By knowing the terminal character we can interpret buffered keys - // as a barcode as soon as it's encountered (instead of waiting x ms) - suffix: /[\n\r\t]+/, - // Keys from a barcode scanner are usually processed as quick as possible, - // but some scanners can use an intercharacter delay (we support <= 50 ms) - max_time_between_keys_in_ms: session.max_time_between_keys_in_ms || 55, - // To be able to receive the barcode value, an input must be focused. - // On mobile devices, this causes the virtual keyboard to open. - // Unfortunately it is not possible to avoid this behavior... - // To avoid keyboard flickering at each detection of a barcode value, - // we want to keep it open for a while (800 ms). - inputTimeOut: 800, - - init: function() { - mixins.PropertiesMixin.init.call(this); - // Keep a reference of the handler functions to use when adding and removing event listeners - this.__keydown_handler = _.bind(this.keydown_handler, this); - this.__keyup_handler = _.bind(this.keyup_handler, this); - this.__handler = _.bind(this.handler, this); - // Bind event handler once the DOM is loaded - // TODO: find a way to be active only when there are listeners on the bus - $(_.bind(this.start, this, false)); - - // Mobile device detection - this.isChromeMobile = config.device.isMobileDevice && navigator.userAgent.match(/Chrome/i); - - // Creates an input who will receive the barcode scanner value. - this.$barcodeInput = $('', { - name: 'barcode', - type: 'text', - css: { - 'position': 'fixed', - 'top': '50%', - 'transform': 'translateY(-50%)', - 'z-index': '-1', - 'opacity': '0', - }, +export const barcodeRemapperService = { + dependencies: ["barcode"], + start(env, { barcode }) { + barcode.bus.addEventListener("barcode_scanned", ev => { + const { barcode, target } = ev.detail; + core.bus.trigger('barcode_scanned', barcode, target); }); - // Avoid to show autocomplete for a non appearing input - this.$barcodeInput.attr('autocomplete', 'off'); - - this.__blurBarcodeInput = _.debounce(this._blurBarcodeInput, this.inputTimeOut); }, - - handle_buffered_keys: function() { - var str = this.buffered_key_events.reduce(function(memo, e) { return memo + String.fromCharCode(e.which) }, ''); - var match = str.match(this.regexp); - - if (match) { - var barcode = match[1]; - - // Send the target in case there are several barcode widgets on the same page (e.g. - // registering the lot numbers in a stock picking) - core.bus.trigger('barcode_scanned', barcode, this.buffered_key_events[0].target); - - // Dispatch a barcode_scanned DOM event to elements that have barcode_events="true" set. - if (this.buffered_key_events[0].target.getAttribute("barcode_events") === "true") - $(this.buffered_key_events[0].target).trigger('barcode_scanned', barcode); - } else { - this.resend_buffered_keys(); - } - - this.buffered_key_events = []; - }, - - resend_buffered_keys: function() { - var old_event, new_event; - for(var i = 0; i < this.buffered_key_events.length; i++) { - old_event = this.buffered_key_events[i]; - - if(old_event.which !== 13) { // ignore returns - // We do not create a 'real' keypress event through - // eg. KeyboardEvent because there are several issues - // with them that make them very different from - // genuine keypresses. Chrome per example has had a - // bug for the longest time that causes keyCode and - // charCode to not be set for events created this way: - // https://bugs.webkit.org/show_bug.cgi?id=16735 - var params = { - 'bubbles': old_event.bubbles, - 'cancelable': old_event.cancelable, - }; - new_event = $.Event('keypress', params); - new_event.viewArg = old_event.viewArg; - new_event.ctrl = old_event.ctrl; - new_event.alt = old_event.alt; - new_event.shift = old_event.shift; - new_event.meta = old_event.meta; - new_event.char = old_event.char; - new_event.key = old_event.key; - new_event.charCode = old_event.charCode; - new_event.keyCode = old_event.keyCode || old_event.which; // Firefox doesn't set keyCode for keypresses, only keyup/down - new_event.which = old_event.which; - new_event.dispatched_by_barcode_reader = true; - - $(old_event.target).trigger(new_event); - } - } - }, - - element_is_editable: function(element) { - return $(element).is('input,textarea,[contenteditable="true"]'); - }, - - // This checks that a keypress event is either ESC, TAB, an arrow - // key or a function key. This is Firefox specific, in Chrom{e,ium} - // keypress events are not fired for these types of keys, only - // keyup/keydown. - is_special_key: function(e) { - if (e.key === "ArrowLeft" || e.key === "ArrowRight" || - e.key === "ArrowUp" || e.key === "ArrowDown" || - e.key === "Escape" || e.key === "Tab" || - e.key === "Backspace" || e.key === "Delete" || - e.key === "Home" || e.key === "End" || - e.key === "PageUp" || e.key === "PageDown" || - e.key === "Unidentified" || /F\d\d?/.test(e.key)) { - return true; - } else { - return false; - } - }, - - // The keydown and keyup handlers are here to disallow key - // repeat. When preventDefault() is called on a keydown event - // the keypress that normally follows is cancelled. - keydown_handler: function(e){ - if (this.key_pressed[e.which]) { - e.preventDefault(); - } else { - this.key_pressed[e.which] = true; - } - }, - - keyup_handler: function(e){ - this.key_pressed[e.which] = false; - }, - - handler: function(e){ - // Don't catch events we resent - if (e.dispatched_by_barcode_reader) - return; - // Don't catch non-printable keys for which Firefox triggers a keypress - if (this.is_special_key(e)) - return; - // Don't catch keypresses which could have a UX purpose (like shortcuts) - if (e.ctrlKey || e.metaKey || e.altKey) - return; - // Don't catch Return when nothing is buffered. This way users - // can still use Return to 'click' on focused buttons or links. - if (e.which === 13 && this.buffered_key_events.length === 0) - return; - // Don't catch events targeting elements that are editable because we - // have no way of redispatching 'genuine' key events. Resent events - // don't trigger native event handlers of elements. So this means that - // our fake events will not appear in eg. an element. - if ((this.element_is_editable(e.target) && !$(e.target).data('enableBarcode')) && e.target.getAttribute("barcode_events") !== "true") - return; - - // Catch and buffer the event - this.buffered_key_events.push(e); - e.preventDefault(); - e.stopImmediatePropagation(); - - // Handle buffered keys immediately if the keypress marks the end - // of a barcode or after x milliseconds without a new keypress - clearTimeout(this.timeout); - if (String.fromCharCode(e.which).match(this.suffix)) { - this.handle_buffered_keys(); - } else { - this.timeout = setTimeout(_.bind(this.handle_buffered_keys, this), this.max_time_between_keys_in_ms); - } - }, - - /** - * Try to detect the barcode value by listening all keydown events: - * Checks if a dom element who may contains text value has the focus. - * If not, it's probably because these events are triggered by a barcode scanner. - * To be able to handle this value, a focused input will be created. - * - * This function also has the responsibility to detect the end of the barcode value. - * (1) In most of cases, an optional key (tab or enter) is sent to mark the end of the value. - * So, we direclty handle the value. - * (2) If no end key is configured, we have to calculate the delay between each keydowns. - * 'max_time_between_keys_in_ms' depends of the device and may be configured. - * Exceeded this timeout, we consider that the barcode value is entirely sent. - * - * @private - * @param {jQuery.Event} e keydown event - */ - _listenBarcodeScanner: function (e) { - if ($(document.activeElement).not('input:text, textarea, [contenteditable], ' + - '[type="email"], [type="number"], [type="password"], [type="tel"], [type="search"]').length) { - $('body').append(this.$barcodeInput); - this.$barcodeInput.focus(); - } - if (this.$barcodeInput.is(":focus")) { - // Handle buffered keys immediately if the keypress marks the end - // of a barcode or after x milliseconds without a new keypress. - clearTimeout(this.timeout); - // On chrome mobile, e.which only works for some special characters like ENTER or TAB. - if (String.fromCharCode(e.which).match(this.suffix)) { - this._handleBarcodeValue(e); - } else { - this.timeout = setTimeout(this._handleBarcodeValue.bind(this, e), - this.max_time_between_keys_in_ms); - } - // if the barcode input doesn't receive keydown for a while, remove it. - this.__blurBarcodeInput(); - } - }, - - /** - * Retrieves the barcode value from the temporary input element. - * This checks this value and trigger it on the bus. - * - * @private - * @param {jQuery.Event} keydown event - */ - _handleBarcodeValue: function (e) { - var barcodeValue = this.$barcodeInput.val(); - if (barcodeValue.match(this.regexp)) { - core.bus.trigger('barcode_scanned', barcodeValue, $(e.target).parent()[0]); - this._blurBarcodeInput(); - } - }, - - /** - * Removes the value and focus from the barcode input. - * If nothing happens, the focus will be lost and - * the virtual keyboard on mobile devices will be closed. - * - * @private - */ - _blurBarcodeInput: function () { - // Close the virtual keyboard on mobile browsers - // FIXME: actually we can't prevent keyboard from opening - this.$barcodeInput.val('').blur(); - }, - - start: function(prevent_key_repeat){ - // Chrome Mobile isn't triggering keypress event. - // This is marked as Legacy in the DOM-Level-3 Standard. - // See: https://www.w3.org/TR/uievents/#legacy-keyboardevent-event-types - // This fix is only applied for Google Chrome Mobile but it should work for - // all other cases. - // In master, we could remove the behavior with keypress and only use keydown. - if (this.isChromeMobile) { - $('body').on("keydown", this._listenBarcodeScanner.bind(this)); - } else { - $('body').bind("keypress", this.__handler); - } - if (prevent_key_repeat === true) { - $('body').bind("keydown", this.__keydown_handler); - $('body').bind('keyup', this.__keyup_handler); - } - }, - - stop: function(){ - $('body').off("keypress", this.__handler); - $('body').off("keydown", this.__keydown_handler); - $('body').off('keyup', this.__keyup_handler); - }, -}); - -return { - /** Singleton that emits barcode_scanned events on core.bus */ - BarcodeEvents: new BarcodeEvents(), - /** - * List of barcode prefixes that are reserved for internal purposes - * @type Array - */ - ReservedBarcodePrefixes: ['O-CMD'], }; - -}); +registry.category("services").add("barcode_remapper", barcodeRemapperService); diff --git a/addons/barcodes/static/src/js/barcode_field.js b/addons/barcodes/static/src/js/barcode_field.js index b5786f8a161..a0105e4a21a 100644 --- a/addons/barcodes/static/src/js/barcode_field.js +++ b/addons/barcodes/static/src/js/barcode_field.js @@ -4,18 +4,13 @@ odoo.define('barcodes.field', function(require) { var AbstractField = require('web.AbstractField'); var basicFields = require('web.basic_fields'); var fieldRegistry = require('web.field_registry'); -var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents; // Field in which the user can both type normally and scan barcodes var FieldFloatScannable = basicFields.FieldFloat.extend({ events: _.extend({}, basicFields.FieldFloat.prototype.events, { - // The barcode_events component intercepts keypresses and releases them when it - // appears they are not part of a barcode. But since released keypresses don't - // trigger native behaviour (like characters input), we must simulate it. - keypress: '_onKeypress', + barcode_scanned: '_onBarcodeScan', }), - //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -27,7 +22,7 @@ var FieldFloatScannable = basicFields.FieldFloat.extend({ _renderEdit: function () { var self = this; return Promise.resolve(this._super()).then(function () { - self.$input.data('enableBarcode', true); + self.$input[0].dataset.enableBarcode = true; }); }, @@ -35,28 +30,11 @@ var FieldFloatScannable = basicFields.FieldFloat.extend({ // Handlers //-------------------------------------------------------------------------- - /** - * @private - * @param {KeyboardEvent} e - */ - _onKeypress: function (e) { - /* only simulate a keypress if it has been previously prevented */ - if (e.dispatched_by_barcode_reader !== true) { - if (!BarcodeEvents.is_special_key(e)) { - e.preventDefault(); - } - return; - } - var character = String.fromCharCode(e.which); - var current_str = e.target.value; - var str_before_carret = current_str.substring(0, e.target.selectionStart); - var str_after_carret = current_str.substring(e.target.selectionEnd); - e.target.value = str_before_carret + character + str_after_carret; - var new_carret_index = str_before_carret.length + character.length; - e.target.setSelectionRange(new_carret_index, new_carret_index); - // trigger an 'input' event to notify the widget that it's value changed - $(e.target).trigger('input'); - }, + _onBarcodeScan() { + // trigger an 'input' event to make sure that the widget is call + // notifyChanges + this.$input.trigger('input'); + } }); // Field to use scan barcodes diff --git a/addons/barcodes/static/src/js/barcode_form_view.js b/addons/barcodes/static/src/js/barcode_form_view.js index 7e3c197ed77..f2a2c022f54 100644 --- a/addons/barcodes/static/src/js/barcode_form_view.js +++ b/addons/barcodes/static/src/js/barcode_form_view.js @@ -1,15 +1,14 @@ odoo.define('barcodes.FormView', function (require) { "use strict"; -var BarcodeEvents = require('barcodes.BarcodeEvents'); // handle to trigger barcode on bus var concurrency = require('web.concurrency'); var core = require('web.core'); -var Dialog = require('web.Dialog'); var FormController = require('web.FormController'); -var FormRenderer = require('web.FormRenderer'); var _t = core._t; +const reservedBarcodePrefixes = ['O-CMD']; + FormController.include({ custom_events: _.extend({}, FormController.prototype.custom_events, { @@ -192,14 +191,12 @@ FormController.include({ */ _barcodeStartListening: function () { core.bus.on('barcode_scanned', this, this._barcodeScanned); - core.bus.on('keypress', this, this._quantityListener); }, /** * @private */ _barcodeStopListening: function () { core.bus.off('barcode_scanned', this, this._barcodeScanned); - core.bus.off('keypress', this, this._quantityListener); }, /** * @private @@ -308,7 +305,7 @@ FormController.include({ _barcodeScanned: function (barcode, target) { var self = this; return this.barcodeMutex.exec(function () { - var prefixed = _.any(BarcodeEvents.ReservedBarcodePrefixes, + var prefixed = _.any(reservedBarcodePrefixes, function (reserved) {return barcode.indexOf(reserved) === 0;}); var hasCommand = false; var defs = []; @@ -344,153 +341,6 @@ FormController.include({ }); }); }, - /** - * @private - * @param {KeyEvent} event - */ - _quantityListener: function (event) { - var character = String.fromCharCode(event.which); - - if (! $.contains(event.target, this.el)) { - return; - } - // only catch the event if we're not focused in - // another field and it's a number - if (!$(event.target).is('body, .modal') || !/[0-9]/.test(character)) { - return; - } - - var barcodeInfos = _.filter(this.activeBarcode, 'setQuantityWithKeypress'); - if (!barcodeInfos.length) { - return; - } - - if (!_.compact(_.pluck(barcodeInfos, 'candidate')).length) { - return this.displayNotification({ message: _t('Scan a barcode to set the quantity'), type: 'danger' }); - } - - for (var k in this.activeBarcode) { - if (this.activeBarcode[k].candidate) { - this._quantityOpenDialog(character, this.activeBarcode[k]); - } - } - }, - /** - * @private - * @param {string} character - * @param {Object} activeBarcode: options sent by the field who use barcode features - */ - _quantityOpenDialog: function (character, activeBarcode) { - var self = this; - var $content = $('