diff options
| author | jbm-odoo <jbm@odoo.com> | 2019-01-14 14:22:03 +0000 |
|---|---|---|
| committer | jbm-odoo <jbm@odoo.com> | 2019-02-14 12:59:10 +0000 |
| commit | 4960453db91b465c9429453ef3a0692a604c8ef9 (patch) | |
| tree | 3b02bb92556b523e91a949817e31da970b6f2a98 /addons/web/static/src | |
| parent | 7410de111fe33731ba79f68f53e13dd3d511fb43 (diff) | |
[IMP] web Add widget signature on binary field
Add a new widget for binary fields. It open a dialog box and the user can sign manually,
or an signature can be draw automatically or he can upload a picture of his signature.
Move fonts, controller, scss,templates about signature from portal to web to avoid redundance
closes odoo/odoo#30222
Diffstat (limited to 'addons/web/static/src')
| -rw-r--r-- | addons/web/static/src/fonts/sign/Aspire-DemiBold.ttf | bin | 0 -> 30040 bytes | |||
| -rw-r--r-- | addons/web/static/src/fonts/sign/JennaSue.ttf | bin | 0 -> 42948 bytes | |||
| -rw-r--r-- | addons/web/static/src/fonts/sign/Otto.ttf | bin | 0 -> 96416 bytes | |||
| -rw-r--r-- | addons/web/static/src/fonts/sign/Pen_Frenzy.ttf | bin | 0 -> 34156 bytes | |||
| -rw-r--r-- | addons/web/static/src/fonts/sign/Prophecy_Script.ttf | bin | 0 -> 69604 bytes | |||
| -rw-r--r-- | addons/web/static/src/fonts/sign/Rhesmanisa.ttf | bin | 0 -> 40532 bytes | |||
| -rw-r--r-- | addons/web/static/src/fonts/sign/khand.ttf | bin | 0 -> 38344 bytes | |||
| -rw-r--r-- | addons/web/static/src/js/fields/basic_fields.js | 1 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_registry.js | 1 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/signature.js | 272 | ||||
| -rw-r--r-- | addons/web/static/src/js/widgets/name_and_signature.js | 648 | ||||
| -rw-r--r-- | addons/web/static/src/scss/name_and_signature.scss | 49 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 44 | ||||
| -rw-r--r-- | addons/web/static/src/xml/name_and_signature.xml | 106 |
14 files changed, 1121 insertions, 0 deletions
diff --git a/addons/web/static/src/fonts/sign/Aspire-DemiBold.ttf b/addons/web/static/src/fonts/sign/Aspire-DemiBold.ttf Binary files differnew file mode 100644 index 00000000000..d8cb1992b86 --- /dev/null +++ b/addons/web/static/src/fonts/sign/Aspire-DemiBold.ttf diff --git a/addons/web/static/src/fonts/sign/JennaSue.ttf b/addons/web/static/src/fonts/sign/JennaSue.ttf Binary files differnew file mode 100644 index 00000000000..667a55112dd --- /dev/null +++ b/addons/web/static/src/fonts/sign/JennaSue.ttf diff --git a/addons/web/static/src/fonts/sign/Otto.ttf b/addons/web/static/src/fonts/sign/Otto.ttf Binary files differnew file mode 100644 index 00000000000..9a2a51dadd2 --- /dev/null +++ b/addons/web/static/src/fonts/sign/Otto.ttf diff --git a/addons/web/static/src/fonts/sign/Pen_Frenzy.ttf b/addons/web/static/src/fonts/sign/Pen_Frenzy.ttf Binary files differnew file mode 100644 index 00000000000..d9f72c59340 --- /dev/null +++ b/addons/web/static/src/fonts/sign/Pen_Frenzy.ttf diff --git a/addons/web/static/src/fonts/sign/Prophecy_Script.ttf b/addons/web/static/src/fonts/sign/Prophecy_Script.ttf Binary files differnew file mode 100644 index 00000000000..57fd1b3647a --- /dev/null +++ b/addons/web/static/src/fonts/sign/Prophecy_Script.ttf diff --git a/addons/web/static/src/fonts/sign/Rhesmanisa.ttf b/addons/web/static/src/fonts/sign/Rhesmanisa.ttf Binary files differnew file mode 100644 index 00000000000..c0ced9d5667 --- /dev/null +++ b/addons/web/static/src/fonts/sign/Rhesmanisa.ttf diff --git a/addons/web/static/src/fonts/sign/khand.ttf b/addons/web/static/src/fonts/sign/khand.ttf Binary files differnew file mode 100644 index 00000000000..f71b7493a3f --- /dev/null +++ b/addons/web/static/src/fonts/sign/khand.ttf diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index ae080f45a11..dcfdc982b7d 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -3005,6 +3005,7 @@ return { FieldEmail: FieldEmail, FieldBinaryFile: FieldBinaryFile, FieldPdfViewer: FieldPdfViewer, + AbstractFieldBinary: AbstractFieldBinary, FieldBinaryImage: FieldBinaryImage, FieldBoolean: FieldBoolean, FieldBooleanButton: FieldBooleanButton, diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index 35d10c5a5e4..a8bd80719e1 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -37,6 +37,7 @@ registry .add('CopyClipboardText', basic_fields.TextCopyClipboard) .add('CopyClipboardChar', basic_fields.CharCopyClipboard) .add('image', basic_fields.FieldBinaryImage) + .add('signature', basic_fields.FieldBinarySignature) .add('binary', basic_fields.FieldBinaryFile) .add('pdf_viewer', basic_fields.FieldPdfViewer) .add('monetary', basic_fields.FieldMonetary) diff --git a/addons/web/static/src/js/fields/signature.js b/addons/web/static/src/js/fields/signature.js new file mode 100644 index 00000000000..186c8fd764c --- /dev/null +++ b/addons/web/static/src/js/fields/signature.js @@ -0,0 +1,272 @@ +odoo.define('web.Signature', function (require) { + "use strict"; + + var AbstractFieldBinary = require('web.basic_fields').AbstractFieldBinary; + var core = require('web.core'); + var Dialog = require('web.Dialog'); + var field_utils = require('web.field_utils'); + var NameAndSignature = require('web.name_and_signature').NameAndSignature; + var registry = require('web.field_registry'); + var session = require('web.session'); + var utils = require('web.utils'); + + + var qweb = core.qweb; + var _t = core._t; + +// The goal of this dialog is to ask the user a signature request. +// It uses @see SignNameAndSignature for the name and signature fields. +var SignatureDialog = Dialog.extend({ + template: 'web.signature_dialog', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web/static/src/xml/name_and_signature.xml'] + ), + custom_events: { + 'signature_changed': '_onChangeSignature', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {Object} options + * @param {string} [options.title='Adopt Your Signature'] - modal title + * @param {string} [options.size='medium'] - modal size + * @param {Object} [options.nameAndSignatureOptions={}] - options for + * @see NameAndSignature.init() + */ + init: function (parent, options) { + var self = this; + options = options || {}; + + options.title = options.title || _t("Adopt Your Signature"); + options.size = options.size || 'medium'; + options.technical = false; + + if (!options.buttons) { + options.buttons = []; + options.buttons.push({text: _t("Adopt and Sign"), classes: "btn-primary", disabled: true, click: function (e) { + self._onConfirm(); + }}); + options.buttons.push({text: _t("Cancel"), close: true}); + } + + this._super(parent, options); + + this.nameAndSignature = new NameAndSignature(this, options.nameAndSignatureOptions); + }, + /** + * Inserts the name and signature. + * + * @override + */ + start: function () { + var self = this; + this.$primaryButton = this.$footer.find('.btn-primary'); + + this.opened().then(function () { + self.nameAndSignature.replace(self.$('.o_web_sign_name_and_signature')); + }); + + return this._super.apply(this, arguments); + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns whether the drawing area is currently empty. + * + * @see NameAndSignature.isSignatureEmpty() + * @returns {boolean} Whether the drawing area is currently empty. + */ + isSignatureEmpty: function () { + return this.nameAndSignature.isSignatureEmpty(); + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Toggles the submit button depending on the signature state. + * + * @private + */ + _onChangeSignature: function () { + var isEmpty = this.nameAndSignature.isSignatureEmpty(); + this.$primaryButton.prop('disabled', isEmpty); + }, + /** + * Upload the signature image when confirm. + * + * @private + */ + _onConfirm: function (fct) { + this.trigger_up('upload_signature', { + name: this.nameAndSignature.getName(), + signatureImage: this.nameAndSignature.getSignatureImage(), + }); + }, +}); + +var FieldBinarySignature = AbstractFieldBinary.extend({ + fieldDependencies: _.extend({}, AbstractFieldBinary.prototype.fieldDependencies, { + __last_update: {type: 'datetime'}, + }), + custom_events: _.extend({}, AbstractFieldBinary.prototype.custom_events, { + upload_signature: '_onUploadSignature', + }), + events: _.extend({}, AbstractFieldBinary.prototype.events, { + 'click .o_signature': '_onClickSignature', + }), + template: null, + supportedFieldTypes: ['binary'], + file_type_magic_word: { + '/': 'jpg', + 'R': 'gif', + 'i': 'png', + 'P': 'svg+xml', + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This widget must always have render even if there are no signature. + * In edit mode, the real value is return to manage required fields. + * + * @override + */ + isSet: function () { + if (this.mode === 'edit') { + return this.value; + } + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders an empty signature or the saved signature. Both must have the same size. + * + * @override + * @private + */ + + _render: function () { + var self = this; + var displaySignatureRatio = 3; + var url; + var $img; + var width = this.nodeOptions.size ? this.nodeOptions.size[0] : this.attrs.width; + var height = this.nodeOptions.size ? this.nodeOptions.size[1] : this.attrs.height; + if (this.value) { + if (!utils.is_bin_size(this.value)) { + // Use magic-word technique for detecting image type + url = 'data:image/' + (this.file_type_magic_word[this.value[0]] || 'png') + ';base64,' + this.value; + } else { + url = session.url('/web/image', { + model: this.model, + id: JSON.stringify(this.res_id), + field: this.nodeOptions.preview_image || this.name, + // unique forces a reload of the image when the record has been updated + unique: field_utils.format.datetime(this.recordData.__last_update).replace(/[^0-9]/g, ''), + }); + } + $img = $(qweb.render("FieldBinarySignature-img", {widget: this, url: url})); + } else { + $img = $('<div class="o_signature o_signature_empty"><svg></svg><p>' + _t('SIGNATURE') + '</p></div>'); + if (width && height) { + width = Math.min(width, displaySignatureRatio * height); + height = width / displaySignatureRatio; + } else if (width) { + height = width / displaySignatureRatio; + } else if (height) { + width = height * displaySignatureRatio; + } + } + if (width) { + $img.attr('width', width); + $img.css('max-width', width + 'px'); + } + if (height) { + $img.attr('height', height); + $img.css('max-height', height + 'px'); + } + this.$('> div').remove(); + this.$('> img').remove(); + + this.$el.prepend($img); + + $img.on('error', function () { + self._clearFile(); + $img.attr('src', self.placeholder); + self.do_warn(_t("Image"), _t("Could not display the selected image.")); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * If the view is in edit mode, open dialog to sign. + * + * @private + */ + _onClickSignature: function () { + var self = this; + if (this.mode === 'edit') { + + var nameAndSignatureOptions = { + mode: 'draw', + displaySignatureRatio: 3, + signatureType: 'signature', + noInputName: true, + }; + + if (this.nodeOptions.full_name) { + var $search = $("[name='" + this.nodeOptions.full_name + "']"); + // If full_name correspond to field in read-only mode + var signName = $($search).text(); + + // If full_name correspond to field to fill + if (!signName && $($search)[0]) { + signName = $($search)[0].value; + } + if (!signName) { + // If full_name is in the recordData. Last option because this value might not be the last value. + signName = this.recordData[this.nodeOptions.full_name] || ''; + } + nameAndSignatureOptions.defaultName = (signName === '') ? undefined : signName; + } + + nameAndSignatureOptions.defaultFont = this.nodeOptions.default_font || ''; + this.signDialog = new SignatureDialog(self, {nameAndSignatureOptions: nameAndSignatureOptions}); + + this.signDialog.open(); + } + }, + + /** + * Upload the signature image if valid and close the dialog. + * + * @private + */ + _onUploadSignature: function (ev) { + var signatureImage = ev.data.signatureImage; + if (signatureImage !== this.signDialog.emptySignature) { + var data = signatureImage[1]; + var type = signatureImage[0].split('/')[1]; + this.on_file_uploaded(data.length, ev.data.name, type, data); + } + this.signDialog.close(); + } +}); + +registry.add('signature', FieldBinarySignature); + +}); diff --git a/addons/web/static/src/js/widgets/name_and_signature.js b/addons/web/static/src/js/widgets/name_and_signature.js new file mode 100644 index 00000000000..53355fc7aa4 --- /dev/null +++ b/addons/web/static/src/js/widgets/name_and_signature.js @@ -0,0 +1,648 @@ +odoo.define('web.name_and_signature', function (require) { +'use strict'; + +var core = require('web.core'); +var Widget = require('web.Widget'); + +var _t = core._t; + +/** + * This widget allows the user to input his name and to draw his signature. + * Alternatively the signature can also be generated automatically based on + * the given name and a selected font, or loaded from an image file. + */ +var NameAndSignature = Widget.extend({ + template: 'web.sign_name_and_signature', + xmlDependencies: ['/web/static/src/xml/name_and_signature.xml'], + events: { + // name + 'input .o_web_sign_name_input': '_onInputSignName', + // signature + 'click .o_web_sign_signature': '_onClickSignature', + 'change .o_web_sign_signature': '_onChangeSignature', + // draw + 'click .o_web_sign_draw_button': '_onClickSignDrawButton', + 'click .o_web_sign_draw_clear a': '_onClickSignDrawClear', + // auto + 'click .o_web_sign_auto_button': '_onClickSignAutoButton', + 'click .o_web_sign_auto_select_style a': '_onClickSignAutoSelectStyle', + 'click .o_web_sign_auto_font_selection a': '_onClickSignAutoFontSelection', + 'mouseover .o_web_sign_auto_font_selection a': '_onMouseOverSignAutoFontSelection', + 'touchmove .o_web_sign_auto_font_selection a': '_onTouchStartSignAutoFontSelection', + // load + 'click .o_web_sign_load_button': '_onClickSignLoadButton', + 'change .o_web_sign_load_file input': '_onChangeSignLoadInput', + }, + + /** + * Allows options. + * + * @constructor + * @param {Widget} parent + * @param {Object} [options={}] + * @param {number} [options.displaySignatureRatio=3.0] - The ratio used when + * (re)computing the size of the signature (width = height * ratio) + * @param {string} [options.defaultName=''] - The default name of + * the signer. + * @param {string} [options.defaultFont=''] - The unique and default + * font for auto mode. If empty, all fonts are visible. + * @param {string} [options.noInputName=false] - If set to true, + * the user can not enter his name. If there aren't defaultName, + * auto mode is hidden. + * @param {string} [options.mode='draw'] - @see this.setMode + * @param {string} [options.signatureType='signature'] - The type of + * signature used in 'auto' mode. Can be one of the following values: + * - 'signature': it will adapt the characters width to fit the whole + * text in the image. + * - 'initial': it will adapt the space between characters to fill + * the image with the text. The text will be the first letter of + * every word in the name, separated by dots. + */ + init: function (parent, options) { + this._super.apply(this, arguments); + options = options || {}; + this.htmlId = _.uniqueId(); + this.defaultName = options.defaultName || ''; + this.defaultFont = options.defaultFont || ''; + this.displaySignatureRatio = options.displaySignatureRatio || 3.0; + this.signatureType = options.signatureType || 'signature'; + this.signMode = options.mode || 'draw'; + this.noInputName = options.noInputName || false; + this.currentFont = 0; + this.drawTimeout = null; + this.drawPreviewTimeout = null; + }, + /** + * Loads the fonts. + * + * @override + */ + willStart: function () { + var self = this; + + return $.when( + this._super.apply(this, arguments), + this._rpc({route: '/web/sign/get_fonts/' + self.defaultFont}).then(function (data) { + self.fonts = data; + }) + ); + + }, + /** + * Finds the DOM elements, initializes the signature area, + * and focus the name field. + * + * @override + */ + start: function () { + var self = this; + // signature and name input + this.$signatureGroup = this.$('.o_web_sign_signature_group'); + this.$signatureField = this.$('.o_web_sign_signature'); + this.$nameInput = this.$('.o_web_sign_name_input'); + this.$nameInputGroup = this.$('.o_web_sign_name_group'); + + // mode selection buttons + this.$drawButton = this.$('a.o_web_sign_draw_button'); + this.$autoButton = this.$('a.o_web_sign_auto_button'); + this.$loadButton = this.$('a.o_web_sign_load_button'); + + // mode: draw + this.$drawClear = this.$('.o_web_sign_draw_clear'); + + // mode: auto + this.$autoSelectStyle = this.$('.o_web_sign_auto_select_style'); + this.$autoFontSelection = this.$('.o_web_sign_auto_font_selection'); + this.$autoFontList = this.$('.o_web_sign_auto_font_list'); + for (var i in this.fonts) { + var $img = $('<img/>').addClass('img-fluid'); + var $a = $('<a/>').addClass('btn p-0').append($img).data('fontNb', i); + this.$autoFontList.append($a); + } + + // mode: load + this.$loadFile = this.$('.o_web_sign_load_file'); + this.$loadInvalid = this.$('.o_web_sign_load_invalid'); + + if (this.fonts && this.fonts.length < 2) { + this.$autoSelectStyle.hide(); + } + + if (this.noInputName) { + if (this.defaultName === "") { + this.$autoButton.hide(); + } + this.$nameInputGroup.hide(); + } + + // Resize the signature area if it is resized + $(window).on('resize.o_web_sign_name_and_signature', _.debounce(function () { + if (self.isDestroyed()) { + // May happen since this is debounced + return; + } + self.resetSignature(); + }, 250)); + + return this._super.apply(this, arguments).then(function () { + // initialize the signature area + return self.resetSignature(); + }); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + $(window).off('resize.o_web_sign_name_and_signature'); + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Focuses the name. + */ + focusName: function () { + this.$nameInput.focus(); + }, + /** + * Gets the name currently given by the user. + * + * @returns {string} name + */ + getName: function () { + return this.$nameInput.val(); + }, + /** + * Gets the signature currently drawn. The data format is that produced + * natively by Canvas - base64 encoded (likely PNG) bitmap data. + * + * @returns {string[]} Array that contains the signature as a bitmap. + * The first element is the mimetype, the second element is the data. + */ + getSignatureImage: function () { + return this.$signatureField.jSignature('getData', 'image'); + }, + /** + * Gets the signature currently drawn, in a format ready to be used in + * an <img/> src attribute. + * + * @returns {string} the signature currently drawn, src ready + */ + getSignatureImageSrc: function () { + return this.$signatureField.jSignature('getData'); + }, + /** + * Returns whether the drawing area is currently empty. + * + * @returns {boolean} Whether the drawing area is currently empty. + */ + isSignatureEmpty: function () { + var signature = this.$signatureField.jSignature('getData'); + return signature && this.emptySignature ? this.emptySignature === signature : true; + }, + /** + * (Re)initializes the signature area: + * - set the correct width and height of the drawing based on the width + * of the container and the ratio option + * - empty any previous content + * - correctly reset the empty state + * - call @see setMode with reset + * + * @returns {Deferred} + */ + resetSignature: function () { + if (!this.$signatureField) { + // no action if called before start + return $.Deferred().reject(); + } + // recompute size based on the current width + this.$signatureField.css({width: 'unset'}); + var width = this.$signatureField.width(); + var height = parseInt(width / this.displaySignatureRatio); + + // necessary because the lib is adding invisible div with margin + // signature field too tall without this code + this.$signatureField.css({ + width: width, + height: height, + }); + + this.$signatureField + .empty() + .jSignature({ + 'decor-color': '#D1D0CE', + 'background-color': '#FFF', + 'color': '#000', + 'lineWidth': 2, + 'width': width, + 'height': height, + }); + + this.emptySignature = this.$signatureField.jSignature('getData'); + + this.setMode(this.signMode, true); + + this.focusName(); + + return $.Deferred().resolve(); + }, + /** + * Changes the signature mode. Toggles the display of the relevant + * controls and resets the drawing. + * + * @param {string} mode - the mode to use. Can be one of the following: + * - 'draw': the user draws the signature manually with the mouse + * - 'auto': the signature is drawn automatically using a selected font + * - 'load': the signature is loaded from an image file + * @param {boolean} [reset=false] - Set to true to reset the elements + * even if the @see mode has not changed. By default nothing happens + * if the @see mode is already selected. + */ + setMode: function (mode, reset) { + if (reset !== true && mode === this.signMode) { + // prevent flickering and unnecessary compute + return; + } + + this.signMode = mode; + + this.$drawClear.toggleClass('d-none', this.signMode !== 'draw'); + this.$autoSelectStyle.toggleClass('d-none', this.signMode !== 'auto'); + this.$loadFile.toggleClass('d-none', this.signMode !== 'load'); + + this.$drawButton.toggleClass('active', this.signMode === 'draw'); + this.$autoButton.toggleClass('active', this.signMode === 'auto'); + this.$loadButton.toggleClass('active', this.signMode === 'load'); + + this.$signatureField.jSignature(this.signMode === 'draw' ? 'enable' : 'disable'); + this.$signatureField.jSignature('reset'); + + if (this.signMode === 'auto') { + // draw based on name + this._drawCurrentName(); + } else { + // close style dialog + this.$autoFontSelection.addClass('d-none'); + } + + if (this.signMode !== 'load') { + // close invalid file alert + this.$loadInvalid.addClass('d-none'); + } + }, + /** + * Gets the current name and signature, validates them, and returns + * the result. If they are invalid, displays the errors to the user. + * + * @returns {boolean} whether the current name and signature are valid + */ + validateSignature: function () { + var name = this.getName(); + var isSignatureEmpty = this.isSignatureEmpty(); + this.$nameInput.parent().toggleClass('o_has_error', !name) + .find('.form-control, .custom-select').toggleClass('is-invalid', !name); + this.$signatureGroup.toggleClass('border-danger', isSignatureEmpty); + return name && !isSignatureEmpty; + }, + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Draws the current name with the current font in the signature field. + * + * @private + */ + _drawCurrentName: function () { + var font = this.fonts[this.currentFont]; + var text = this._getCleanedName(); + var canvas = this.$signatureField.find('canvas')[0]; + var img = this._getSVGText(font, text, canvas.width, canvas.height); + return this._printImage(img); + }, + /** + * Returns the given name after cleaning it by removing characters that + * are not supposed to be used in a signature. If @see signatureType is set + * to 'initial', returns the first letter of each word, separated by dots. + * + * @private + * @returns {string} cleaned name + */ + _getCleanedName: function () { + var regexRemoveSpecialCharacters = /[^\w\u00E0-\u00FC-'" ]/g; + var text = this.getName().replace(regexRemoveSpecialCharacters, ''); + if (this.signatureType === 'initial') { + return (text.split(' ').map(function (w) { + return w[0]; + }).join('.') + '.'); + } + return text; + }, + /** + * Gets an SVG matching the given parameters, output compatible with the + * src attribute of <img/>. + * + * @private + * @param {string} font: base64 encoded font to use + * @param {string} text: the name to draw + * @param {number} width: the width of the resulting image in px + * @param {number} height: the height of the resulting image in px + * @returns {string} image = mimetype + image data + */ + _getSVGText: function (font, text, width, height) { + var $svg = $(core.qweb.render('web.sign_svg_text', { + width: width, + height: height, + font: font, + text: text, + type: this.signatureType, + })); + $svg.attr({ + 'xmlns': "http://www.w3.org/2000/svg", + 'xmlns:xlink': "http://www.w3.org/1999/xlink", + }); + + return "data:image/svg+xml," + encodeURI($svg[0].outerHTML); + }, + /** + * Displays the given image in the signature field. + * If needed, resizes the image to fit the existing area. + * + * @private + * @param {string} imgSrc - data of the image to display + */ + _printImage: function (imgSrc) { + var self = this; + + var image = new Image; + image.onload = function () { + // don't slow down the UI if the drawing is slow, and prevent + // drawing twice when calling this method in rapid succession + clearTimeout(self.drawTimeout); + self.drawTimeout = setTimeout(function () { + var width = 0; + var height = 0; + var ratio = image.width / image.height; + + var $canvas = self.$signatureField.find('canvas'); + var context = $canvas[0].getContext('2d'); + + if (image.width / $canvas[0].width > image.height / $canvas[0].height) { + width = $canvas[0].width; + height = parseInt(width / ratio); + } else { + height = $canvas[0].height; + width = parseInt(height * ratio); + } + self.$signatureField.jSignature('reset'); + var ignoredContext = _.pick(context, ['shadowOffsetX', 'shadowOffsetY']); + _.extend(context, {shadowOffsetX: 0, shadowOffsetY: 0}); + context.drawImage(image, + 0, + 0, + image.width, + image.height, + ($canvas[0].width - width) / 2, + ($canvas[0].height - height) / 2, + width, + height + ); + _.extend(context, ignoredContext); + self.trigger_up('signature_changed'); + }, 0); + }; + image.src = imgSrc; + }, + /** + * Sets the font to use in @see mode 'auto'. Redraws the signature if + * the font has been changed. + * + * @private + * @param {number} index - index of the font in @see this.fonts + */ + _setFont: function (index) { + if (index !== this.currentFont) { + this.currentFont = index; + this._drawCurrentName(); + } + }, + /** + * Updates the preview buttons by rendering the signature for each font. + * + * @private + */ + _updatePreviewButtons: function () { + var self = this; + // don't slow down the UI if the drawing is slow, and prevent + // drawing twice when calling this method in rapid succession + clearTimeout(this.drawPreviewTimeout); + this.drawPreviewTimeout = setTimeout(function () { + var height = 100; + var width = parseInt(height * self.displaySignatureRatio); + var $existingButtons = self.$autoFontList.find('a'); + for (var i = 0; i < self.fonts.length; i++) { + var imgSrc = self._getSVGText( + self.fonts[i], + self._getCleanedName() || _t("Your name"), + width, + height + ); + $existingButtons.eq(i).find('img').attr('src', imgSrc); + } + }, 0); + }, + /** + * Waits for the signature to be not empty and triggers up the event + * `signature_changed`. + * This is necessary because some methods of jSignature are async but + * they don't return a promise and don't trigger any event. + * + * @private + * @param {Deferred} [def=Deferred] - Deferred that will be returned by + * the method and resolved when the signature is not empty anymore. + * @returns {Deferred} + */ + _waitForSignatureNotEmpty: function (def) { + def = def || $.Deferred(); + if (!this.isSignatureEmpty()) { + this.trigger_up('signature_changed'); + def.resolve(); + } else { + // Use the existing def to prevent the method from creating a new + // one at every loop. + setTimeout(this._waitForSignatureNotEmpty.bind(this, def), 10); + } + return def; + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Handles click on the signature: closes the font selection. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onClickSignature: function (ev) { + this.$autoFontSelection.addClass('d-none'); + }, + /** + * Handles click on the Auto button: activates @see mode 'auto'. + * + * @private + * @param {Event} ev + */ + _onClickSignAutoButton: function (ev) { + ev.preventDefault(); + this.setMode('auto'); + }, + /** + * Handles click on a font: uses it and closes the font selection. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onClickSignAutoFontSelection: function (ev) { + this.$autoFontSelection.addClass('d-none').removeClass('d-flex').css('width', 0); + this._setFont(parseInt($(ev.currentTarget).data('font-nb'))); + }, + /** + * Handles click on Select Style: opens and updates the font selection. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onClickSignAutoSelectStyle: function (ev) { + var self = this; + var width = Math.min( + self.$autoFontSelection.find('a').first().height() * self.displaySignatureRatio * 1.25, + this.$signatureField.width() + ); + + ev.preventDefault(); + self._updatePreviewButtons(); + + this.$autoFontSelection.removeClass('d-none').addClass('d-flex'); + this.$autoFontSelection.show().animate({'width': width}, 500, function () {}); + }, + /** + * Handles click on the Draw button: activates @see mode 'draw'. + * + * @private + * @param {Event} ev + */ + _onClickSignDrawButton: function (ev) { + ev.preventDefault(); + this.setMode('draw'); + }, + /** + * Handles click on clear: empties the signature field. + * + * @see mode 'draw' + * @private + * @param {Event} ev + */ + _onClickSignDrawClear: function (ev) { + ev.preventDefault(); + this.$signatureField.jSignature('reset'); + }, + /** + * Handles click on the Load button: activates @see mode 'load'. + * + * @private + * @param {Event} ev + */ + _onClickSignLoadButton: function (ev) { + ev.preventDefault(); + // open file upload automatically (saves 1 click) + this.$loadFile.find('input').click(); + this.setMode('load'); + }, + /** + * Triggers up the signature change event. + * + * @private + * @param {Event} ev + */ + _onChangeSignature: function (ev) { + this.trigger_up('signature_changed'); + }, + /** + * Handles change on load file input: displays the loaded image if the + * format is correct, or diplays an error otherwise. + * + * @see mode 'load' + * @private + * @param {Event} ev + * @return bool|undefined + */ + _onChangeSignLoadInput: function (ev) { + var self = this; + var f = ev.target.files[0]; + if (f === undefined) { + return false; + } + if (f.type.substr(0, 5) !== 'image') { + this.$signatureField.jSignature('reset'); + this.$loadInvalid.removeClass('d-none'); + return false; + } + this.$loadInvalid.addClass('d-none'); + + var reader = new FileReader(); + reader.onload = function (ev) { + self._printImage(reader.result); + }; + reader.readAsDataURL(f); + }, + /** + * Handles input on name field: if the @see mode is 'auto', redraws the + * signature with the new name. Also updates the font selection if open. + * + * @private + * @param {Event} ev + */ + _onInputSignName: function (ev) { + if (this.signMode !== 'auto') { + return; + } + this._drawCurrentName(); + if (!this.$autoFontSelection.hasClass('d-none')) { + this._updatePreviewButtons(); + } + }, + /** + * Handles mouse over on font selection: uses this font. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onMouseOverSignAutoFontSelection: function (ev) { + this._setFont(parseInt($(ev.currentTarget).data('font-nb'))); + }, + /** + * Handles touch start on font selection: uses this font. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onTouchStartSignAutoFontSelection: function (ev) { + this._setFont(parseInt($(ev.currentTarget).data('font-nb'))); + }, +}); + +return { + NameAndSignature: NameAndSignature, +}; +}); diff --git a/addons/web/static/src/scss/name_and_signature.scss b/addons/web/static/src/scss/name_and_signature.scss new file mode 100644 index 00000000000..9dc32d5396b --- /dev/null +++ b/addons/web/static/src/scss/name_and_signature.scss @@ -0,0 +1,49 @@ +.o_web_sign_name_and_signature { + // for the absolute of the font selection to work + position: relative; +} + +/* Signature Dialog */ +// The .card selector is important to make sure the position absolute will take +// priority over the position relative of the card. +.card.o_web_sign_auto_font_selection { + @include o-position-absolute(0, 0); + + .o_web_sign_auto_font_list { + overflow: auto; + + > a { + height: 100px; + + > img { + height: 100%; + } + } + } +} + +.o_field_widget { + .o_signature { + outline: 1px solid rgba(theme-color('secondary'), 0.3); + position: relative; + > p { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } +} + +.o_field_invalid { + .o_signature { + outline: 3px solid $o-notification-error-bg-color; + cursor: pointer; + } +} +.o_form_editable { + .o_signature:hover { + outline: 3px solid $o-enterprise-primary-color; + cursor: pointer; + } +} diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index dc40c962333..563d6f979ae 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1006,6 +1006,50 @@ </div> </t> +<t t-name="FieldBinarySignature-img"> + <img class="o_signature img img-fluid" + alt="Binary file" + t-att-src='url' + t-att-border="1" + t-att-name="widget.name"/> +</t> + +<div t-name="FieldBinarySignature"> + <div class="o_sign_signature_draw panel panel-default mt16 mb0"> + <div class="card-header"> + <a role="button" class="o_sign_select_style float-right btn btn-link">Select Signature Style</a> + <a role="button" class="o_sign_clean float-right btn btn-link">Clear Signature</a> + <a data-mode="draw" class="o_sign_mode o_sign_mode_draw btn btn-primary" role="button">Draw</a> + <a data-mode="auto" class="o_sign_mode o_sign_mode_auto btn" role="button">Auto</a> + <a data-mode="load" class="o_sign_mode o_sign_mode_load btn" role="button">Load</a> + <input type="file" name="files[]" class="o_sign_load btn btn-link" role="button"/> + </div> + <div class="o_sign_signature card-body"/> + </div> + <div class="mt16 small">By clicking Adopt and Sign, I agree that the chosen signature/initials will be a valid electronic representation of my hand-written signature/initials for all purposes when it is used on documents, including legally binding contracts.</div> + + <div class="o_sign_font_dialog card"> + <div class="card-header">Styles</div> + <div class="o_sign_font_selection card-body"/> + </div> +</div> + +<t t-name="SignatureSvgText" name="SVG Signature Text"> + <svg t-att-width="width" t-att-height="height"> + <defs> + <style type="text/css"> + @font-face { + font-family: "font_sign"; + src: url(data:font/ttf;base64,<t t-esc="font"/>) format("woff"); + font-weight: normal; + font-style: normal; + } + </style> + </defs> + <text x="50%" t-att-y="height*4/5" t-att-font-size="height*4/5" t-att-textLength="width * 4 / 5 * Math.min(1, text.length / 7)" lengthAdjust="spacingAndGlyphs" style="font-family:'font_sign'" fill="black" text-anchor="middle"><t t-esc="text"/></text> + </svg> +</t> + <t t-name="FieldPdfViewer"> <div class="o_field_pdfviewer" aria-atomic="true"> <div t-if="widget.mode !== 'readonly'" class="o_form_pdf_controls mt8" role="toolbar" aria-label="PDF controls"> diff --git a/addons/web/static/src/xml/name_and_signature.xml b/addons/web/static/src/xml/name_and_signature.xml new file mode 100644 index 00000000000..bf910fa14ea --- /dev/null +++ b/addons/web/static/src/xml/name_and_signature.xml @@ -0,0 +1,106 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <!-- + This template is used to generate the SVG containing the signature when + using mode 'auto'. + + The following variables have to be defined: + - width: (number) the width of the resulting image in px + - height: (number) the height of the resulting image in px + - font: base64 encoded font to use + - text: (string) the name to draw + - type: (string) + -> if set to 'signature', it will adapt the characters width to + fit the whole text in the image. + -> otherwise, it will adapt the space between characters to fill + the image with the text. + --> + <t t-name="web.sign_svg_text" name="SVG Signature Text"> + <svg t-att-width="width" t-att-height="height"> + <defs> + <style type="text/css"> + @font-face { + font-family: "font"; + src: url(data:font/ttf;base64,<t t-esc="font"/>) format("woff"); + font-weight: normal; + font-style: normal; + } + </style> + </defs> + <!-- adapt textLength to prevent distortion and left overflow with small words --> + <text t-esc="text" x="50%" t-att-y="height * 4 / 5" t-att-font-size="height * 4 / 5" + t-att-textLength="width * 4 / 5 * (type === 'signature' ? Math.min(1, text.length / 7) : 1)" + t-att-lengthAdjust="'spacing' + (type === 'signature' ? 'AndGlyphs' : '')" + style="font-family:'font'" fill="black" text-anchor="middle"/> + </svg> + </t> + + <!-- Template for the widget webSignNameAndDraw. --> + <t t-name="web.sign_name_and_signature"> + <div class="o_web_sign_name_and_signature"> + <div class="o_web_sign_name_group form-group"> + <label class="col-form-label" t-att-for="'o_web_sign_name_input_' + widget.htmlId">Full Name</label> + <input type="text" name="signer" t-att-id="'o_web_sign_name_input_' + widget.htmlId" class="o_web_sign_name_input form-control" t-att-value="widget.defaultName" placeholder="Your name" required=""/> + </div> + + <!-- + Overflow hidden is necessary because the card has a rounded + border, but we remove the padding from the body, so the canvas + will overflow on the corners without this rule. + --> + <div class="o_web_sign_signature_group card mt-3" style="overflow: hidden;"> + <div class="card-header"> + <div class="row no-gutters"> + <div class="col-auto"> + <a role="button" href="#" t-attf-class="o_web_sign_draw_button mr-2 btn btn-light {{ widget.signMode === 'draw' ? 'active': '' }}"> + Draw + </a> + </div> + <div class="col-auto"> + <a role="button" href="#" t-attf-class="o_web_sign_auto_button mr-2 btn btn-light {{ widget.signMode === 'auto' ? 'active': '' }}"> + Auto + </a> + </div> + <div class="col-auto"> + <a role="button" href="#" t-attf-class="o_web_sign_load_button mr-2 btn btn-light {{ widget.signMode === 'load' ? 'active': '' }}"> + Load + </a> + </div> + <div t-attf-class="o_web_sign_draw_clear col-auto ml-auto {{ widget.signMode !== 'draw' ? 'd-none' : '' }}"> + <a role="button" href="#" class="btn btn-link"> + Clear + </a> + </div> + <div t-attf-class="o_web_sign_auto_select_style col-auto ml-auto {{ widget.signMode !== 'auto' ? 'd-none' : '' }}"> + <a role="button" href="#" class="btn btn-link"> + Style + </a> + </div> + <div t-attf-class="o_web_sign_load_file col-auto {{ widget.signMode !== 'load' ? 'd-none' : '' }}"> + <input type="file" role="button" name="files[]" class="btn btn-link py-0"/> + </div> + </div> + </div> + + <div class="o_web_sign_signature card-body p-0"/> + + <div t-attf-class="o_web_sign_load_invalid card-footer d-none"> + <div class="alert alert-danger mb-0" role="alert"> + This file is invalid. Please select an image. + </div> + </div> + </div> + + <div class="o_web_sign_auto_font_selection card d-none h-100 flex-column"> + <div class="card-header">Styles</div> + <div class="o_web_sign_auto_font_list card-body text-center p-3 flex-grow-1"/> + </div> + </div> + </t> + <div t-name="web.signature_dialog"> + <div class="o_web_sign_name_and_signature"/> + + <div class="mt16 small">By clicking Adopt and Sign, I agree that the chosen signature/initials will be a valid electronic representation of my hand-written signature/initials for all purposes when it is used on documents, including legally binding contracts.</div> + </div> +</templates> |
