summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/libs
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/libs')
-rw-r--r--addons/web/static/src/js/libs/autocomplete.js38
-rw-r--r--addons/web/static/src/js/libs/bootstrap.js131
-rw-r--r--addons/web/static/src/js/libs/content-disposition.js249
-rw-r--r--addons/web/static/src/js/libs/daterangepicker.js24
-rw-r--r--addons/web/static/src/js/libs/download.js153
-rw-r--r--addons/web/static/src/js/libs/fullcalendar.js252
-rw-r--r--addons/web/static/src/js/libs/jquery.js235
-rw-r--r--addons/web/static/src/js/libs/pdfjs.js20
-rw-r--r--addons/web/static/src/js/libs/popper.js2
-rw-r--r--addons/web/static/src/js/libs/underscore.js52
-rw-r--r--addons/web/static/src/js/libs/zoomodoo.js353
11 files changed, 1509 insertions, 0 deletions
diff --git a/addons/web/static/src/js/libs/autocomplete.js b/addons/web/static/src/js/libs/autocomplete.js
new file mode 100644
index 00000000..72f3ee89
--- /dev/null
+++ b/addons/web/static/src/js/libs/autocomplete.js
@@ -0,0 +1,38 @@
+odoo.define('web.autocomplete.extensions', function () {
+'use strict';
+
+/**
+ * The jquery autocomplete library extensions and fixes should be done here to
+ * avoid patching in place.
+ */
+
+// jquery autocomplete tweak to allow html and classnames
+var proto = $.ui.autocomplete.prototype;
+var initSource = proto._initSource;
+
+function filter( array, term ) {
+ var matcher = new RegExp( $.ui.autocomplete.escapeRegex(term), "i" );
+ return $.grep( array, function (value_) {
+ return matcher.test( $( "<div>" ).html( value_.label || value_.value || value_ ).text() );
+ });
+}
+
+$.extend(proto, {
+ _initSource: function () {
+ if ( this.options.html && $.isArray(this.options.source) ) {
+ this.source = function (request, response) {
+ response( filter( this.options.source, request.term ) );
+ };
+ } else {
+ initSource.call( this );
+ }
+ },
+ _renderItem: function (ul, item) {
+ return $( "<li></li>" )
+ .data( "item.autocomplete", item )
+ .append( $( "<a></a>" )[ this.options.html ? "html" : "text" ]( item.label ) )
+ .appendTo( ul )
+ .addClass(item.classname);
+ },
+});
+});
diff --git a/addons/web/static/src/js/libs/bootstrap.js b/addons/web/static/src/js/libs/bootstrap.js
new file mode 100644
index 00000000..9854b47a
--- /dev/null
+++ b/addons/web/static/src/js/libs/bootstrap.js
@@ -0,0 +1,131 @@
+odoo.define('web.bootstrap.extensions', function () {
+'use strict';
+
+/**
+ * The bootstrap library extensions and fixes should be done here to avoid
+ * patching in place.
+ */
+
+/**
+ * Review Bootstrap Sanitization: leave it enabled by default but extend it to
+ * accept more common tag names like tables and buttons, and common attributes
+ * such as style or data-. If a specific tooltip or popover must accept custom
+ * tags or attributes, they must be supplied through the whitelist BS
+ * parameter explicitely.
+ *
+ * We cannot disable sanitization because bootstrap uses tooltip/popover
+ * DOM attributes in an "unsafe" way.
+ */
+var bsSanitizeWhiteList = $.fn.tooltip.Constructor.Default.whiteList;
+
+bsSanitizeWhiteList['*'].push('title', 'style', /^data-[\w-]+/);
+
+bsSanitizeWhiteList.header = [];
+bsSanitizeWhiteList.main = [];
+bsSanitizeWhiteList.footer = [];
+
+bsSanitizeWhiteList.caption = [];
+bsSanitizeWhiteList.col = ['span'];
+bsSanitizeWhiteList.colgroup = ['span'];
+bsSanitizeWhiteList.table = [];
+bsSanitizeWhiteList.thead = [];
+bsSanitizeWhiteList.tbody = [];
+bsSanitizeWhiteList.tfooter = [];
+bsSanitizeWhiteList.tr = [];
+bsSanitizeWhiteList.th = ['colspan', 'rowspan'];
+bsSanitizeWhiteList.td = ['colspan', 'rowspan'];
+
+bsSanitizeWhiteList.address = [];
+bsSanitizeWhiteList.article = [];
+bsSanitizeWhiteList.aside = [];
+bsSanitizeWhiteList.blockquote = [];
+bsSanitizeWhiteList.section = [];
+
+bsSanitizeWhiteList.button = ['type'];
+bsSanitizeWhiteList.del = [];
+
+/**
+ * Returns an extended version of bootstrap default whitelist for sanitization,
+ * i.e. a version where, for each key, the original value is concatened with the
+ * received version's value and where the received version's extra key/values
+ * are added.
+ *
+ * Note: the returned version
+ *
+ * @param {Object} extensions
+ * @returns {Object} /!\ the returned whitelist is made from a *shallow* copy of
+ * the default whitelist, extended with given whitelist.
+ */
+function makeExtendedSanitizeWhiteList(extensions) {
+ var whiteList = _.clone($.fn.tooltip.Constructor.Default.whiteList);
+ Object.keys(extensions).forEach(key => {
+ whiteList[key] = (whiteList[key] || []).concat(extensions[key]);
+ });
+ return whiteList;
+}
+
+/* Bootstrap tooltip defaults overwrite */
+$.fn.tooltip.Constructor.Default.placement = 'auto';
+$.fn.tooltip.Constructor.Default.fallbackPlacement = ['bottom', 'right', 'left', 'top'];
+$.fn.tooltip.Constructor.Default.html = true;
+$.fn.tooltip.Constructor.Default.trigger = 'hover';
+$.fn.tooltip.Constructor.Default.container = 'body';
+$.fn.tooltip.Constructor.Default.boundary = 'window';
+$.fn.tooltip.Constructor.Default.delay = { show: 1000, hide: 0 };
+
+var bootstrapShowFunction = $.fn.tooltip.Constructor.prototype.show;
+$.fn.tooltip.Constructor.prototype.show = function () {
+ // Overwrite bootstrap tooltip method to prevent showing 2 tooltip at the
+ // same time
+ $('.tooltip').remove();
+
+ return bootstrapShowFunction.call(this);
+};
+
+/* Bootstrap scrollspy fix for non-body to spy */
+
+const bootstrapSpyRefreshFunction = $.fn.scrollspy.Constructor.prototype.refresh;
+$.fn.scrollspy.Constructor.prototype.refresh = function () {
+ bootstrapSpyRefreshFunction.apply(this, arguments);
+ if (this._scrollElement === window || this._config.method !== 'offset') {
+ return;
+ }
+ const baseScrollTop = this._getScrollTop();
+ for (let i = 0; i < this._offsets.length; i++) {
+ this._offsets[i] += baseScrollTop;
+ }
+};
+
+/**
+ * In some cases, we need to keep the first element of navbars selected.
+ */
+const bootstrapSpyProcessFunction = $.fn.scrollspy.Constructor.prototype._process;
+$.fn.scrollspy.Constructor.prototype._process = function () {
+ bootstrapSpyProcessFunction.apply(this, arguments);
+ if (this._activeTarget === null && this._config.alwaysKeepFirstActive) {
+ this._activate(this._targets[0]);
+ }
+};
+
+/* Bootstrap modal scrollbar compensation on non-body */
+const bsSetScrollbarFunction = $.fn.modal.Constructor.prototype._setScrollbar;
+$.fn.modal.Constructor.prototype._setScrollbar = function () {
+ const $scrollable = $().getScrollingElement();
+ if (document.body.contains($scrollable[0])) {
+ $scrollable.compensateScrollbar(true);
+ }
+ return bsSetScrollbarFunction.apply(this, arguments);
+};
+const bsResetScrollbarFunction = $.fn.modal.Constructor.prototype._resetScrollbar;
+$.fn.modal.Constructor.prototype._resetScrollbar = function () {
+ const $scrollable = $().getScrollingElement();
+ if (document.body.contains($scrollable[0])) {
+ $scrollable.compensateScrollbar(false);
+ }
+ return bsResetScrollbarFunction.apply(this, arguments);
+};
+
+return {
+ makeExtendedSanitizeWhiteList: makeExtendedSanitizeWhiteList,
+};
+});
diff --git a/addons/web/static/src/js/libs/content-disposition.js b/addons/web/static/src/js/libs/content-disposition.js
new file mode 100644
index 00000000..d229bd4c
--- /dev/null
+++ b/addons/web/static/src/js/libs/content-disposition.js
@@ -0,0 +1,249 @@
+/*
+(The MIT License)
+
+Copyright (c) 2014-2017 Douglas Christopher Wilson
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+/**
+ * Stripped down to only parsing/decoding.
+ */
+odoo.define('web.contentdisposition', function () {
+'use strict';
+
+/**
+ * RegExp to match percent encoding escape.
+ * @private
+ */
+var HEX_ESCAPE_REPLACE_REGEXP = /%([0-9A-Fa-f]{2})/g;
+
+/**
+ * RegExp to match non-latin1 characters.
+ * @private
+ */
+var NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g;
+
+/**
+ * RegExp to match quoted-pair in RFC 2616
+ *
+ * quoted-pair = "\" CHAR
+ * CHAR = <any US-ASCII character (octets 0 - 127)>
+ * @private
+ */
+var QESC_REGEXP = /\\([\u0000-\u007f])/g;
+
+/**
+ * RegExp for various RFC 2616 grammar
+ *
+ * parameter = token "=" ( token | quoted-string )
+ * token = 1*<any CHAR except CTLs or separators>
+ * separators = "(" | ")" | "<" | ">" | "@"
+ * | "," | ";" | ":" | "\" | <">
+ * | "/" | "[" | "]" | "?" | "="
+ * | "{" | "}" | SP | HT
+ * quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
+ * qdtext = <any TEXT except <">>
+ * quoted-pair = "\" CHAR
+ * CHAR = <any US-ASCII character (octets 0 - 127)>
+ * TEXT = <any OCTET except CTLs, but including LWS>
+ * LWS = [CRLF] 1*( SP | HT )
+ * CRLF = CR LF
+ * CR = <US-ASCII CR, carriage return (13)>
+ * LF = <US-ASCII LF, linefeed (10)>
+ * SP = <US-ASCII SP, space (32)>
+ * HT = <US-ASCII HT, horizontal-tab (9)>
+ * CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)>
+ * OCTET = <any 8-bit sequence of data>
+ * @private
+ */
+var PARAM_REGEXP = /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g;
+
+/**
+ * RegExp for various RFC 5987 grammar
+ *
+ * ext-value = charset "'" [ language ] "'" value-chars
+ * charset = "UTF-8" / "ISO-8859-1" / mime-charset
+ * mime-charset = 1*mime-charsetc
+ * mime-charsetc = ALPHA / DIGIT
+ * / "!" / "#" / "$" / "%" / "&"
+ * / "+" / "-" / "^" / "_" / "`"
+ * / "{" / "}" / "~"
+ * language = ( 2*3ALPHA [ extlang ] )
+ * / 4ALPHA
+ * / 5*8ALPHA
+ * extlang = *3( "-" 3ALPHA )
+ * value-chars = *( pct-encoded / attr-char )
+ * pct-encoded = "%" HEXDIG HEXDIG
+ * attr-char = ALPHA / DIGIT
+ * / "!" / "#" / "$" / "&" / "+" / "-" / "."
+ * / "^" / "_" / "`" / "|" / "~"
+ * @private
+ */
+var EXT_VALUE_REGEXP = /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/;
+
+/**
+ * RegExp for various RFC 6266 grammar
+ *
+ * disposition-type = "inline" | "attachment" | disp-ext-type
+ * disp-ext-type = token
+ * disposition-parm = filename-parm | disp-ext-parm
+ * filename-parm = "filename" "=" value
+ * | "filename*" "=" ext-value
+ * disp-ext-parm = token "=" value
+ * | ext-token "=" ext-value
+ * ext-token = <the characters in token, followed by "*">
+ * @private
+ */
+var DISPOSITION_TYPE_REGEXP = /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/;
+
+/**
+ * Decode a RFC 6987 field value (gracefully).
+ *
+ * @param {string} str
+ * @return {string}
+ * @private
+ */
+function decodefield(str) {
+ var match = EXT_VALUE_REGEXP.exec(str);
+
+ if (!match) {
+ throw new TypeError('invalid extended field value')
+ }
+
+ var charset = match[1].toLowerCase();
+ var encoded = match[2];
+
+ switch (charset) {
+ case 'iso-8859-1':
+ return encoded.replace(HEX_ESCAPE_REPLACE_REGEXP, pdecode).replace(NON_LATIN1_REGEXP, '?');
+ case 'utf-8':
+ return decodeURIComponent(encoded);
+ default:
+ throw new TypeError('unsupported charset in extended field')
+ }
+}
+
+/**
+ * Parse Content-Disposition header string.
+ *
+ * @param {string} string
+ * @return {ContentDisposition}
+ * @public
+ */
+function parse(string) {
+ if (!string || typeof string !== 'string') {
+ throw new TypeError('argument string is required')
+ }
+
+ var match = DISPOSITION_TYPE_REGEXP.exec(string);
+
+ if (!match) {
+ throw new TypeError('invalid type format')
+ }
+
+ // normalize type
+ var index = match[0].length;
+ var type = match[1].toLowerCase();
+
+ var key;
+ var names = [];
+ var params = {};
+ var value;
+
+ // calculate index to start at
+ index = PARAM_REGEXP.lastIndex = match[0].substr(-1) === ';' ? index - 1 : index;
+
+ // match parameters
+ while ((match = PARAM_REGEXP.exec(string))) {
+ if (match.index !== index) {
+ throw new TypeError('invalid parameter format')
+ }
+
+ index += match[0].length;
+ key = match[1].toLowerCase();
+ value = match[2];
+
+ if (names.indexOf(key) !== -1) {
+ throw new TypeError('invalid duplicate parameter')
+ }
+
+ names.push(key);
+
+ if (key.indexOf('*') + 1 === key.length) {
+ // decode extended value
+ key = key.slice(0, -1);
+ value = decodefield(value);
+
+ // overwrite existing value
+ params[key] = value;
+ continue
+ }
+
+ if (typeof params[key] === 'string') {
+ continue
+ }
+
+ if (value[0] === '"') {
+ // remove quotes and escapes
+ value = value
+ .substr(1, value.length - 2)
+ .replace(QESC_REGEXP, '$1')
+ }
+
+ params[key] = value
+ }
+
+ if (index !== -1 && index !== string.length) {
+ throw new TypeError('invalid parameter format')
+ }
+
+ return new ContentDisposition(type, params)
+}
+
+/**
+ * Percent decode a single character.
+ *
+ * @param {string} str
+ * @param {string} hex
+ * @return {string}
+ * @private
+ */
+function pdecode(str, hex) {
+ return String.fromCharCode(parseInt(hex, 16))
+}
+
+/**
+ * Class for parsed Content-Disposition header for v8 optimization
+ *
+ * @public
+ * @param {string} type
+ * @param {object} parameters
+ * @constructor
+ */
+function ContentDisposition(type, parameters) {
+ this.type = type;
+ this.parameters = parameters
+}
+
+return {
+ parse: parse,
+};
+});
diff --git a/addons/web/static/src/js/libs/daterangepicker.js b/addons/web/static/src/js/libs/daterangepicker.js
new file mode 100644
index 00000000..dd731eb4
--- /dev/null
+++ b/addons/web/static/src/js/libs/daterangepicker.js
@@ -0,0 +1,24 @@
+odoo.define('web.daterangepicker.extensions', function () {
+'use strict';
+
+/**
+ * Don't allow user to select off days(Dates which are out of current calendar).
+ */
+var clickDateFunction = daterangepicker.prototype.clickDate;
+daterangepicker.prototype.clickDate = function (ev) {
+ if (!$(ev.target).hasClass('off')) {
+ clickDateFunction.apply(this, arguments);
+ }
+};
+
+/**
+ * Override to open up or down based on top/bottom space in window.
+ */
+const moveFunction = daterangepicker.prototype.move;
+daterangepicker.prototype.move = function () {
+ const offset = this.element.offset();
+ this.drops = this.container.height() < offset.top ? 'up' : 'down';
+ moveFunction.apply(this, arguments);
+};
+
+});
diff --git a/addons/web/static/src/js/libs/download.js b/addons/web/static/src/js/libs/download.js
new file mode 100644
index 00000000..97a015f0
--- /dev/null
+++ b/addons/web/static/src/js/libs/download.js
@@ -0,0 +1,153 @@
+/*
+MIT License
+
+Copyright (c) 2016 dandavis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+
+/**
+ * download.js v4.2, by dandavis; 2008-2018. [MIT] see http://danml.com/download.html for tests/usage
+ *
+ * @param {Blob | File | String} data
+ * @param {String} [filename]
+ * @param {String} [mimetype]
+ */
+odoo.define('web.download', function () {
+return function download(data, filename, mimetype) {
+ var self = window, // this script is only for browsers anyway...
+ defaultMime = "application/octet-stream", // this default mime also triggers iframe downloads
+ mimeType = mimetype || defaultMime, payload = data,
+ url = !filename && !mimetype && payload,
+ anchor = document.createElement("a"),
+ toString = function (a) {return String(a);},
+ myBlob = (self.Blob || self.MozBlob || self.WebKitBlob || toString),
+ fileName = filename || "download", blob, reader;
+ myBlob = myBlob.call ? myBlob.bind(self) : Blob;
+
+ if (String(this) === "true") { //reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback
+ payload = [payload, mimeType];
+ mimeType = payload[0];
+ payload = payload[1];
+ }
+
+ if (url && url.length < 2048) { // if no filename and no mime, assume a url was passed as the only argument
+ fileName = url.split("/").pop().split("?")[0];
+ anchor.href = url; // assign href prop to temp anchor
+ if (anchor.href.indexOf(url) !== -1) { // if the browser determines that it's a potentially valid url path:
+ var ajax = new XMLHttpRequest();
+ ajax.open("GET", url, true);
+ ajax.responseType = 'blob';
+ ajax.onload = function (e) {
+ download(e.target.response, fileName, defaultMime);
+ };
+ setTimeout(function () { ajax.send();}, 0); // allows setting custom ajax headers using the return:
+ return ajax;
+ }
+ }
+
+ //go ahead and download dataURLs right away
+ if (/^data:[\w+\-]+\/[\w+\-]+[,;]/.test(payload)) {
+
+ if (payload.length > (1024 * 1024 * 1.999) && myBlob !== toString) {
+ payload = dataUrlToBlob(payload);
+ mimeType = payload.type || defaultMime;
+ } else {
+ return navigator.msSaveBlob ? // IE10 can't do a[download], only Blobs:
+ navigator.msSaveBlob(dataUrlToBlob(payload), fileName) : saver(payload); // everyone else can save dataURLs un-processed
+ }
+
+ }
+
+ blob = payload instanceof myBlob ? payload : new myBlob([payload], {type: mimeType});
+
+
+ function dataUrlToBlob(strUrl) {
+ var parts = strUrl.split(/[:;,]/), type = parts[1],
+ decoder = parts[2] === "base64" ? atob : decodeURIComponent,
+ binData = decoder(parts.pop()), mx = binData.length,
+ i = 0, uiArr = new Uint8Array(mx);
+
+ for (i; i < mx; ++i) uiArr[i] = binData.charCodeAt(i);
+
+ return new myBlob([uiArr], {type: type});
+ }
+
+ function saver(url, winMode) {
+ if ('download' in anchor) { //html5 A[download]
+ anchor.href = url;
+ anchor.setAttribute("download", fileName);
+ anchor.className = "download-js-link";
+ anchor.innerHTML = "downloading...";
+ anchor.style.display = "none";
+ document.body.appendChild(anchor);
+ setTimeout(function () {
+ anchor.click();
+ document.body.removeChild(anchor);
+ if (winMode === true) {setTimeout(function () { self.URL.revokeObjectURL(anchor.href);}, 250);}
+ }, 66);
+ return true;
+ }
+
+ // handle non-a[download] safari as best we can:
+ if (/(Version)\/(\d+)\.(\d+)(?:\.(\d+))?.*Safari\//.test(navigator.userAgent)) {
+ url = url.replace(/^data:([\w\/\-+]+)/, defaultMime);
+ if (!window.open(url)) { // popup blocked, offer direct download:
+ if (confirm("Displaying New Document\n\nUse Save As... to download, then click back to return to this page.")) { location.href = url; }
+ }
+ return true;
+ }
+
+ //do iframe dataURL download (old ch+FF):
+ var f = document.createElement("iframe");
+ document.body.appendChild(f);
+
+ if (!winMode) { // force a mime that will download:
+ url = "data:" + url.replace(/^data:([\w\/\-+]+)/, defaultMime);
+ }
+ f.src = url;
+ setTimeout(function () { document.body.removeChild(f); }, 333);
+ }
+
+ if (navigator.msSaveBlob) { // IE10+ : (has Blob, but not a[download] or URL)
+ return navigator.msSaveBlob(blob, fileName);
+ }
+
+ if (self.URL) { // simple fast and modern way using Blob and URL:
+ saver(self.URL.createObjectURL(blob), true);
+ } else {
+ // handle non-Blob()+non-URL browsers:
+ if (typeof blob === "string" || blob.constructor === toString) {
+ try {
+ return saver("data:" + mimeType + ";base64," + self.btoa(blob));
+ } catch (y) {
+ return saver("data:" + mimeType + "," + encodeURIComponent(blob));
+ }
+ }
+
+ // Blob but not URL support:
+ reader = new FileReader();
+ reader.onload = function () {
+ saver(this.result);
+ };
+ reader.readAsDataURL(blob);
+ }
+ return true;
+};
+});
diff --git a/addons/web/static/src/js/libs/fullcalendar.js b/addons/web/static/src/js/libs/fullcalendar.js
new file mode 100644
index 00000000..7f9714e8
--- /dev/null
+++ b/addons/web/static/src/js/libs/fullcalendar.js
@@ -0,0 +1,252 @@
+odoo.define('/web/static/src/js/libs/fullcalendar.js', function () {
+ "use strict";
+
+ function createYearCalendarView(FullCalendar) {
+ const {
+ Calendar,
+ createElement,
+ EventApi,
+ memoizeRendering,
+ View,
+ } = FullCalendar;
+
+ class YearView extends View {
+ constructor() {
+ super(...arguments);
+ this.months = null;
+ this.renderSubCalendarsMem = memoizeRendering(
+ this.renderSubCalendars, this.unrenderSubCalendars);
+ this.events = [];
+ }
+
+ //----------------------------------------------------------------------
+ // Getters
+ //----------------------------------------------------------------------
+
+ get currentDate() {
+ return this.context.calendar.state.currentDate;
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ destroy() {
+ this.renderSubCalendarsMem.unrender();
+ super.destroy();
+ }
+ /**
+ * Removes the selection on sub calendar.
+ * Selections on sub calendars are not propagated to this view so
+ * this view cannot manage them.
+ */
+ unselect() {
+ for (const { calendar } of this.months) {
+ calendar.unselect();
+ }
+ }
+ /**
+ * @override
+ */
+ render() {
+ this.renderSubCalendarsMem(this.context);
+ super.render(...arguments);
+ }
+ /**
+ * Renders the main layout (the 4x3 month grid)
+ */
+ renderSubCalendars() {
+ this.el.classList.add('fc-scroller');
+ if (!this.context.options.selectable) {
+ this.el.classList.add('fc-readonly-year-view');
+ }
+ this.months = [];
+ for (let monthNumber = 0; monthNumber < 12; monthNumber++) {
+ const monthDate = new Date(this.currentDate.getFullYear(), monthNumber);
+ const monthShortName = moment(monthDate).format('MMM').toLowerCase();
+ const container = createElement('div', { class: 'fc-month-container' });
+ this.el.appendChild(container);
+ const el = createElement('div', {
+ class: `fc-month fc-month-${monthShortName}`,
+ });
+ container.appendChild(el);
+ const calendar = this._createMonthCalendar(el, monthDate);
+ this.months.push({ el, calendar });
+ calendar.render();
+ }
+ }
+ /**
+ * Removes the main layout (the 4x3 month grid).
+ * Called when view is switched/destroyed.
+ */
+ unrenderSubCalendars() {
+ for (const { el, calendar } of this.months) {
+ calendar.destroy();
+ el.remove();
+ }
+ }
+ /**
+ * Renders events in sub calendars.
+ * Called every time event source changed (when changing the date,
+ * when changing filters, adding/removing filters).
+ */
+ renderEvents() {
+ // `renderDates` also renders events so if it's called just before
+ // then do not execute this as it will do a re-render.
+ if (this.datesRendered) {
+ this.datesRendered = false;
+ return;
+ }
+ this.events = this._computeEvents();
+ for (const { calendar } of this.months) {
+ calendar.refetchEvents();
+ }
+ this._setCursorOnEventDates();
+ }
+ /**
+ * Renders dates and events in sub calendars.
+ * Called when the year of the date changed to render a new
+ * 4*3 grid of month calendar based on the new year.
+ */
+ renderDates() {
+ this.events = this._computeEvents();
+ for (const [monthNumber, { calendar }] of Object.entries(this.months)) {
+ const monthDate = new Date(this.currentDate.getFullYear(), monthNumber);
+ calendar.gotoDate(monthDate);
+ }
+ this._setCursorOnEventDates();
+ this.datesRendered = true;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _computeEvents() {
+ const calendar = this.context.calendar;
+ return calendar.getEvents().map(event => {
+ const endUTC = calendar.dateEnv.toDate(event._instance.range.end);
+ const end = new Date(event._instance.range.end);
+ if (endUTC.getHours() > 0 || endUTC.getMinutes() > 0 ||
+ endUTC.getSeconds() > 0 || endUTC.getMilliseconds() > 0) {
+ end.setDate(end.getDate() + 1);
+ }
+ // clone event data to not trigger rerendering and issues
+ const instance = Object.assign({}, event._instance, {
+ range: { start: new Date(event._instance.range.start), end },
+ });
+ const def = Object.assign({}, event._def, {
+ rendering: 'background',
+ allDay: true,
+ });
+ return new EventApi(this.context.calendar, def, instance);
+ });
+ }
+ /**
+ * Create a month calendar for the date `monthDate` and mount it on container.
+ *
+ * @private
+ * @param {HTMLElement} container
+ * @param {Date} monthDate
+ */
+ _createMonthCalendar(container, monthDate) {
+ return new Calendar(container, Object.assign({}, this.context.options, {
+ defaultDate: monthDate,
+ defaultView: 'dayGridMonth',
+ header: { left: false, center: 'title', right: false },
+ titleFormat: { month: 'short', year: 'numeric' },
+ height: 0,
+ contentHeight: 0,
+ weekNumbers: false,
+ showNonCurrentDates: false,
+ views: {
+ dayGridMonth: {
+ columnHeaderText: (date) => moment(date).format("ddd")[0],
+ },
+ },
+ selectMinDistance: 5, // needed to not trigger select when click
+ dateClick: this._onYearDateClick.bind(this),
+ datesRender: undefined,
+ events: (info, successCB) => {
+ successCB(this.events);
+ },
+ windowResize: undefined,
+ }));
+ }
+ /**
+ * Sets fc-has-event class on every dates that have at least one event.
+ *
+ * @private
+ */
+ _setCursorOnEventDates() {
+ for (const el of this.el.querySelectorAll('.fc-has-event')) {
+ el.classList.remove('fc-has-event');
+ }
+ for (const event of Object.values(this.events)) {
+ let currentDate = moment(event._instance.range.start);
+ while (currentDate.isBefore(event._instance.range.end, 'day')) {
+ const formattedDate = currentDate.format('YYYY-MM-DD');
+ const el = this.el.querySelector(`.fc-day-top[data-date="${formattedDate}"]`);
+ if (el) {
+ el.classList.add('fc-has-event');
+ }
+ currentDate.add(1, 'days');
+ }
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {*} info
+ */
+ _onYearDateClick(info) {
+ const calendar = this.context.calendar;
+ const events = Object.values(this.events)
+ .filter(event => {
+ const startUTC = calendar.dateEnv.toDate(event._instance.range.start);
+ const endUTC = calendar.dateEnv.toDate(event._instance.range.end);
+ const start = moment(startUTC);
+ const end = moment(endUTC);
+ const inclusivity = start.isSame(end, 'day') ? '[]' : '[)';
+ return moment(info.date).isBetween(start, end, 'day', inclusivity);
+ })
+ .map(event => {
+ return Object.assign({}, event._def, event._instance.range);
+ });
+ const yearDateInfo = Object.assign({}, info, {
+ view: this,
+ monthView: info.view,
+ events,
+ selectable: this.context.options.selectable,
+ });
+ calendar.publiclyTrigger('yearDateClick', [yearDateInfo]);
+ }
+ }
+
+ return FullCalendar.createPlugin({
+ views: {
+ dayGridYear: {
+ class: YearView,
+ duration: { years: 1 },
+ defaults: {
+ fixedWeekCount: true,
+ },
+ },
+ },
+ });
+ }
+
+ return {
+ createYearCalendarView,
+ };
+});
diff --git a/addons/web/static/src/js/libs/jquery.js b/addons/web/static/src/js/libs/jquery.js
new file mode 100644
index 00000000..749bcd80
--- /dev/null
+++ b/addons/web/static/src/js/libs/jquery.js
@@ -0,0 +1,235 @@
+odoo.define('web.jquery.extensions', function () {
+'use strict';
+
+/**
+ * The jquery library extensions and fixes should be done here to avoid patching
+ * in place.
+ */
+
+// jQuery selectors extensions
+$.extend($.expr[':'], {
+ containsLike: function (element, index, matches){
+ return element.innerHTML.toUpperCase().indexOf(matches[3].toUpperCase()) >= 0;
+ },
+ containsTextLike: function (element, index, matches){
+ return element.innerText.toUpperCase().indexOf(matches[3].toUpperCase()) >= 0;
+ },
+ containsExact: function (element, index, matches){
+ return $.trim(element.innerHTML) === matches[3];
+ },
+ containsExactText: function (element, index, matches) {
+ return element.innerText.trim() === matches[3].trim();
+ },
+ /**
+ * Note all escaped characters need to be double escaped inside of the
+ * expression, so "\(" needs to be "\\("
+ */
+ containsRegex: function (element, index, matches){
+ var regreg = /^\/((?:\\\/|[^\/])+)\/([mig]{0,3})$/,
+ reg = regreg.exec(matches[3]);
+ return reg ? new RegExp(reg[1], reg[2]).test($.trim(element.innerHTML)) : false;
+ },
+ propChecked: function (element, index, matches) {
+ return $(element).prop("checked") === true;
+ },
+ propSelected: function (element, index, matches) {
+ return $(element).prop("selected") === true;
+ },
+ propValue: function (element, index, matches) {
+ return $(element).prop("value") === matches[3];
+ },
+ propValueContains: function (element, index, matches) {
+ return $(element).prop("value") && $(element).prop("value").indexOf(matches[3]) !== -1;
+ },
+ hasData: function (element) {
+ return !!_.toArray(element.dataset).length;
+ },
+ data: function (element, index, matches) {
+ return $(element).data(matches[3]);
+ },
+ hasVisibility: function (element, index, matches) {
+ var $element = $(element);
+ if ($(element).css('visibility') === 'hidden') {
+ return false;
+ }
+ var $parent = $element.parent();
+ if (!$parent.length || $element.is('html')) {
+ return true;
+ }
+ return $parent.is(':hasVisibility');
+ },
+ hasOpacity: function (element, index, matches) {
+ var $element = $(element);
+ if (parseFloat($(element).css('opacity')) <= 0.01) {
+ return false;
+ }
+ var $parent = $element.parent();
+ if (!$parent.length || $element.is('html')) {
+ return true;
+ }
+ return $parent.is(':hasOpacity');
+ },
+});
+
+// jQuery functions extensions
+$.fn.extend({
+ /**
+ * Returns all the attributes of a DOM element (first one in the jQuery
+ * set).
+ *
+ * @returns {Object} attribute name -> attribute value
+ */
+ getAttributes: function () {
+ var o = {};
+ if (this.length) {
+ var attrs = this[0].attributes;
+ for (var i = 0, l = attrs.length ; i < l ; i++) {
+ var attr = attrs.item(i);
+ o[attr.name] = attr.value;
+ }
+ }
+ return o;
+ },
+ /**
+ * Makes DOM elements bounce the way Odoo decided it.
+ *
+ * @param {string} [extraClass]
+ */
+ odooBounce: function (extraClass) {
+ for (const el of this) {
+ el.classList.add('o_catch_attention', extraClass);
+ setTimeout(() => el.classList.remove('o_catch_attention', extraClass), 400);
+ }
+ return this;
+ },
+ /**
+ * Allows to bind events to a handler just as the standard `$.on` function
+ * but binds the handler so that it is executed before any already-attached
+ * handler for the same events.
+ *
+ * @see jQuery.on
+ */
+ prependEvent: function (events, selector, data, handler) {
+ this.on.apply(this, arguments);
+
+ events = events.split(' ');
+ return this.each(function () {
+ var el = this;
+ _.each(events, function (evNameNamespaced) {
+ var evName = evNameNamespaced.split('.')[0];
+ var handler = $._data(el, 'events')[evName].pop();
+ $._data(el, 'events')[evName].unshift(handler);
+ });
+ });
+ },
+ /**
+ * @return {jQuery}
+ */
+ closestScrollable() {
+ let $el = this;
+ while ($el[0] !== document.scrollingElement) {
+ if ($el.isScrollable()) {
+ return $el;
+ }
+ $el = $el.parent();
+ }
+ return $el;
+ },
+ /**
+ * Adapt the given css property by adding the size of a scrollbar if any.
+ * Limitation: only works if the given css property is not already used as
+ * inline style for another reason.
+ *
+ * @param {boolean} [add=true]
+ * @param {boolean} [isScrollElement=true]
+ * @param {string} [cssProperty='padding-right']
+ */
+ compensateScrollbar(add = true, isScrollElement = true, cssProperty = 'padding-right') {
+ for (const el of this) {
+ // Compensate scrollbar
+ el.style.removeProperty(cssProperty);
+ if (!add) {
+ return;
+ }
+ const scrollableEl = isScrollElement ? el : $(el).parent().closestScrollable()[0];
+ const style = window.getComputedStyle(el);
+ const newValue = parseInt(style[cssProperty]) + scrollableEl.offsetWidth - scrollableEl.clientWidth;
+ el.style.setProperty(cssProperty, `${newValue}px`, 'important');
+ }
+ },
+ /**
+ * @returns {jQuery}
+ */
+ getScrollingElement() {
+ const $baseScrollingElement = $(document.scrollingElement);
+ if ($baseScrollingElement.isScrollable()
+ && $baseScrollingElement.hasScrollableContent()) {
+ return $baseScrollingElement;
+ }
+ const bodyHeight = $(document.body).height();
+ for (const el of document.body.children) {
+ // Search for a body child which is at least as tall as the body
+ // and which has the ability to scroll if enough content in it. If
+ // found, suppose this is the top scrolling element.
+ if (bodyHeight - el.scrollHeight > 1.5) {
+ continue;
+ }
+ const $el = $(el);
+ if ($el.isScrollable()) {
+ return $el;
+ }
+ }
+ return $baseScrollingElement;
+ },
+ /**
+ * @return {boolean}
+ */
+ hasScrollableContent() {
+ return this[0].scrollHeight > this[0].clientHeight;
+ },
+ /**
+ * @returns {boolean}
+ */
+ isScrollable() {
+ const overflow = this.css('overflow-y');
+ return overflow === 'auto' || overflow === 'scroll'
+ || (overflow === 'visible' && this === document.scrollingElement);
+ },
+});
+
+// jQuery functions monkey-patching
+
+// Some magic to ensure scrolltop and animate on html/body animate the top level
+// scrollable element even if not html or body.
+const originalScrollTop = $.fn.scrollTop;
+$.fn.scrollTop = function (value) {
+ if (value !== undefined && this.filter('html, body').length) {
+ // The caller wants to scroll a set of elements including html and/or
+ // body to a specific point -> do that but make sure to add the real
+ // top level element to that set of elements if any different is found.
+ originalScrollTop.apply(this.not('html, body').add($().getScrollingElement()), arguments);
+ return this;
+ } else if (value === undefined && this.eq(0).is('html, body')) {
+ // The caller wants to get the scroll point of a set of elements, jQuery
+ // will return the scroll point of the first one, if it is html or body
+ // return the scroll point of the real top level element.
+ return originalScrollTop.apply($().getScrollingElement(), arguments);
+ }
+ return originalScrollTop.apply(this, arguments);
+};
+const originalAnimate = $.fn.animate;
+$.fn.animate = function (properties, ...rest) {
+ const props = Object.assign({}, properties);
+ if ('scrollTop' in props && this.filter('html, body').length) {
+ // The caller wants to scroll a set of elements including html and/or
+ // body to a specific point -> do that but make sure to add the real
+ // top level element to that set of elements if any different is found.
+ originalAnimate.call(this.not('html, body').add($().getScrollingElement()), {'scrollTop': props['scrollTop']}, ...rest);
+ delete props['scrollTop'];
+ }
+ if (!Object.keys(props).length) {
+ return this;
+ }
+ return originalAnimate.call(this, props, ...rest);
+};
+});
diff --git a/addons/web/static/src/js/libs/pdfjs.js b/addons/web/static/src/js/libs/pdfjs.js
new file mode 100644
index 00000000..92c1e560
--- /dev/null
+++ b/addons/web/static/src/js/libs/pdfjs.js
@@ -0,0 +1,20 @@
+/*
+* There is no changes to pdf.js in this file, but only a note about a change that has been done in it.
+*
+* In the module account_invoice_extract, the the code need to react to the 'pagerendered' event triggered by
+* pdf.js. However in recent version of pdf.js, event are not visible outside of the library, except if the
+* 'eventBusDispatchToDOM' has been set to true.
+*
+* We tried to set this option from outside of the library but without success, as our pdf viewer is in an iframe.
+* There is no state of the iframe in which we can add an event listener to set the option.
+* pdf.js has an event used to signal when we can set settings, called 'webviewerloaded'.
+* This event is triggered in an EventListener attached to the 'DOMContentLoaded' event.
+* So, to list options we had, we could:
+* a) add an eventListener to the iframe document or window to react to 'webviewerloaded'. This doesn't work as
+* document and windows are not the definitive ones and won't catche the event later.
+* b) add an eventListener to the iframe to react to 'DOMContentLoaded', which doens't work too as our listener will be called
+* after the pdf.js one.
+*
+* Finally the option was choosed to modify the default value of this option directly in pdf.js as no hook worked in the
+* 'account_invoice_extract' module.
+*/
diff --git a/addons/web/static/src/js/libs/popper.js b/addons/web/static/src/js/libs/popper.js
new file mode 100644
index 00000000..e94cf40c
--- /dev/null
+++ b/addons/web/static/src/js/libs/popper.js
@@ -0,0 +1,2 @@
+/** @odoo-module **/
+Popper.Defaults.modifiers.preventOverflow.priority = ['right', 'left', 'bottom', 'top'];
diff --git a/addons/web/static/src/js/libs/underscore.js b/addons/web/static/src/js/libs/underscore.js
new file mode 100644
index 00000000..083ee22f
--- /dev/null
+++ b/addons/web/static/src/js/libs/underscore.js
@@ -0,0 +1,52 @@
+/**
+ * The _throttle in underscore has the feature to cancel the throttled function
+ * only starting version 1.9.0
+ * @todo remove this in master and update underscorejs to 1.9.1
+ */
+
+// Returns a function, that, when invoked, will only be triggered at most once
+// during a given window of time. Normally, the throttled function will run
+// as much as it can, without ever going more than once per `wait` duration;
+// but if you'd like to disable the execution on the leading edge, pass
+// `{leading: false}`. To disable execution on the trailing edge, ditto.
+
+_.cancellableThrottleRemoveMeSoon = function (func, wait, options) {
+ var timeout, context, args, result;
+ var previous = 0;
+ if (!options) options = {};
+
+ var later = function () {
+ previous = options.leading === false ? 0 : _.now();
+ timeout = null;
+ result = func.apply(context, args);
+ if (!timeout) context = args = null;
+ };
+
+ var throttled = function () {
+ var now = _.now();
+ if (!previous && options.leading === false) previous = now;
+ var remaining = wait - (now - previous);
+ context = this;
+ args = arguments;
+ if (remaining <= 0 || remaining > wait) {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ previous = now;
+ result = func.apply(context, args);
+ if (!timeout) context = args = null;
+ } else if (!timeout && options.trailing !== false) {
+ timeout = setTimeout(later, remaining);
+ }
+ return result;
+ };
+
+ throttled.cancel = function () {
+ clearTimeout(timeout);
+ previous = 0;
+ timeout = context = args = null;
+ };
+
+ return throttled;
+};
diff --git a/addons/web/static/src/js/libs/zoomodoo.js b/addons/web/static/src/js/libs/zoomodoo.js
new file mode 100644
index 00000000..07da384c
--- /dev/null
+++ b/addons/web/static/src/js/libs/zoomodoo.js
@@ -0,0 +1,353 @@
+odoo.define('web.zoomodoo', function (require) {
+'use strict';
+
+/**
+ This code has been more that widely inspired by easyZoom library.
+
+ Copyright 2013 Matt Hinchliffe
+
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+**/
+
+var dw, dh, rw, rh, lx, ly;
+
+var defaults = {
+
+ // Attribute to retrieve the zoom image URL from.
+ linkTag: 'a',
+ linkAttribute: 'data-zoom-image',
+
+ // event to trigger zoom
+ event: 'click', //or mouseenter
+
+ // Timer before trigger zoom
+ timer: 0,
+
+ // Prevent clicks on the zoom image link.
+ preventClicks: true,
+
+ // disable on mobile
+ disabledOnMobile: true,
+
+ // Callback function to execute before the flyout is displayed.
+ beforeShow: $.noop,
+
+ // Callback function to execute before the flyout is removed.
+ beforeHide: $.noop,
+
+ // Callback function to execute when the flyout is displayed.
+ onShow: $.noop,
+
+ // Callback function to execute when the flyout is removed.
+ onHide: $.noop,
+
+ // Callback function to execute when the cursor is moved while over the image.
+ onMove: $.noop,
+
+ // Callback function to execute when the flyout is attached to the target.
+ beforeAttach: $.noop
+
+};
+
+/**
+ * ZoomOdoo
+ * @constructor
+ * @param {Object} target
+ * @param {Object} options (Optional)
+ */
+function ZoomOdoo(target, options) {
+ this.$target = $(target);
+ this.opts = $.extend({}, defaults, options, this.$target.data());
+
+ if (this.isOpen === undefined) {
+ this._init();
+ }
+}
+
+/**
+ * Init
+ * @private
+ */
+ZoomOdoo.prototype._init = function () {
+ if (window.outerWidth > 467 || !this.opts.disabledOnMobile) {
+ this.$link = this.$target.find(this.opts.linkTag).length && this.$target.find(this.opts.linkTag) || this.$target;
+ this.$image = this.$target.find('img').length && this.$target.find('img') || this.$target;
+ this.$flyout = $('<div class="zoomodoo-flyout" />');
+
+ var $attach = this.$target;
+ if (this.opts.attach !== undefined && this.$target.closest(this.opts.attach).length) {
+ $attach = this.$target.closest(this.opts.attach);
+ }
+ $attach.parent().on('mousemove.zoomodoo touchmove.zoomodoo', $.proxy(this._onMove, this));
+ $attach.parent().on('mouseleave.zoomodoo touchend.zoomodoo', $.proxy(this._onLeave, this));
+ this.$target.on(this.opts.event + '.zoomodoo touchstart.zoomodoo', $.proxy(this._onEnter, this));
+
+ if (this.opts.preventClicks) {
+ this.$target.on('click.zoomodoo', function (e) { e.preventDefault(); });
+ } else {
+ var self = this;
+ this.$target.on('click.zoomodoo', function () { self.hide(); self.$target.unbind(); });
+ }
+ }
+};
+
+/**
+ * Show
+ * @param {MouseEvent|TouchEvent} e
+ * @param {Boolean} testMouseOver (Optional)
+ */
+ZoomOdoo.prototype.show = function (e, testMouseOver) {
+ var w1, h1, w2, h2;
+ var self = this;
+
+ if (this.opts.beforeShow.call(this) === false) return;
+
+ if (!this.isReady) {
+ return this._loadImage(this.$link.attr(this.opts.linkAttribute), function () {
+ if (self.isMouseOver || !testMouseOver) {
+ self.show(e);
+ }
+ });
+ }
+
+ var $attach = this.$target;
+ if (this.opts.attach !== undefined && this.$target.closest(this.opts.attach).length) {
+ $attach = this.$target.closest(this.opts.attach);
+ }
+
+ // Prevents having multiple zoom flyouts
+ $attach.parent().find('.zoomodoo-flyout').remove();
+ this.$flyout.removeAttr('style');
+ $attach.parent().append(this.$flyout);
+
+ if (this.opts.attachToTarget) {
+ this.opts.beforeAttach.call(this);
+
+ // Be sure that the flyout is at top 0, left 0 to ensure correct computation
+ // e.g. employees kanban on dashboard
+ this.$flyout.css('position', 'fixed');
+ var flyoutOffset = this.$flyout.offset();
+ if (flyoutOffset.left > 0) {
+ var flyoutLeft = parseFloat(this.$flyout.css('left').replace('px',''));
+ this.$flyout.css('left', flyoutLeft - flyoutOffset.left + 'px');
+ }
+ if (flyoutOffset.top > 0) {
+ var flyoutTop = parseFloat(this.$flyout.css('top').replace('px',''));
+ this.$flyout.css('top', flyoutTop - flyoutOffset.top + 'px');
+ }
+
+ if(this.$zoom.height() < this.$flyout.height()) {
+ this.$flyout.css('height', this.$zoom.height() + 'px');
+ }
+ if(this.$zoom.width() < this.$flyout.width()) {
+ this.$flyout.css('width', this.$zoom.width() + 'px');
+ }
+
+ var offset = this.$target.offset();
+ var left = offset.left - this.$flyout.width();
+ var top = offset.top;
+
+ // Position the zoom on the right side of the target
+ // if there's not enough room on the left
+ if(left < 0) {
+ if(offset.left < ($(document).width() / 2)) {
+ left = offset.left + this.$target.width();
+ } else {
+ left = 0;
+ }
+ }
+
+ // Prevents the flyout to overflow
+ if(left + this.$flyout.width() > $(document).width()) {
+ this.$flyout.css('width', $(document).width() - left + 'px');
+ } else if(left === 0) { // Limit the max width if displayed on the left
+ this.$flyout.css('width', offset.left + 'px');
+ }
+
+ // Prevents the zoom to be displayed outside the current viewport
+ if((top + this.$flyout.height()) > $(document).height()) {
+ top = $(document).height() - this.$flyout.height();
+ }
+
+ this.$flyout.css('transform', 'translate3d(' + left + 'px, ' + top + 'px, 0px)');
+ } else {
+ // Computing flyout max-width depending to the available space on the right to avoid overflow-x issues
+ // e.g. width too high so a right zoomed element is not visible (need to scroll on x axis)
+ var rightAvailableSpace = document.body.clientWidth - this.$flyout[0].getBoundingClientRect().left;
+ this.$flyout.css('max-width', rightAvailableSpace);
+ }
+
+ w1 = this.$target[0].offsetWidth;
+ h1 = this.$target[0].offsetHeight;
+
+ w2 = this.$flyout.width();
+ h2 = this.$flyout.height();
+
+ dw = this.$zoom.width() - w2;
+ dh = this.$zoom.height() - h2;
+
+ // For the case where the zoom image is actually smaller than
+ // the flyout.
+ if (dw < 0) dw = 0;
+ if (dh < 0) dh = 0;
+
+ rw = dw / w1;
+ rh = dh / h1;
+
+ this.isOpen = true;
+
+ this.opts.onShow.call(this);
+
+ if (e) {
+ this._move(e);
+ }
+};
+
+/**
+ * On enter
+ * @private
+ * @param {Event} e
+ */
+ZoomOdoo.prototype._onEnter = function (e) {
+ var self = this;
+ var touches = e.originalEvent.touches;
+ e.preventDefault();
+ this.isMouseOver = true;
+
+ setTimeout(function () {
+ if (self.isMouseOver && (!touches || touches.length === 1)) {
+ self.show(e, true);
+ }
+ }, this.opts.timer);
+
+};
+
+/**
+ * On move
+ * @private
+ * @param {Event} e
+ */
+ZoomOdoo.prototype._onMove = function (e) {
+ if (!this.isOpen) return;
+
+ e.preventDefault();
+ this._move(e);
+};
+
+/**
+ * On leave
+ * @private
+ */
+ZoomOdoo.prototype._onLeave = function () {
+ this.isMouseOver = false;
+ if (this.isOpen) {
+ this.hide();
+ }
+};
+
+/**
+ * On load
+ * @private
+ * @param {Event} e
+ */
+ZoomOdoo.prototype._onLoad = function (e) {
+ // IE may fire a load event even on error so test the image dimensions
+ if (!e.currentTarget.width) return;
+
+ this.isReady = true;
+
+ this.$flyout.html(this.$zoom);
+
+ if (e.data.call) {
+ e.data();
+ }
+};
+
+/**
+ * Load image
+ * @private
+ * @param {String} href
+ * @param {Function} callback
+ */
+ZoomOdoo.prototype._loadImage = function (href, callback) {
+ var zoom = new Image();
+
+ this.$zoom = $(zoom).on('load', callback, $.proxy(this._onLoad, this));
+
+ zoom.style.position = 'absolute';
+ zoom.src = href;
+};
+
+/**
+ * Move
+ * @private
+ * @param {Event} e
+ */
+ZoomOdoo.prototype._move = function (e) {
+ if (e.type.indexOf('touch') === 0) {
+ var touchlist = e.touches || e.originalEvent.touches;
+ lx = touchlist[0].pageX;
+ ly = touchlist[0].pageY;
+ } else {
+ lx = e.pageX || lx;
+ ly = e.pageY || ly;
+ }
+
+ var offset = this.$target.offset();
+ var pt = ly - offset.top;
+ var pl = lx - offset.left;
+ var xt = Math.ceil(pt * rh);
+ var xl = Math.ceil(pl * rw);
+
+ // Close if outside
+ if (!this.opts.attachToTarget && (xl < 0 || xt < 0 || xl > dw || xt > dh || lx > (offset.left + this.$target.outerWidth()))) {
+ this.hide();
+ } else {
+ var top = xt * -1;
+ var left = xl * -1;
+
+ this.$zoom.css({
+ top: top,
+ left: left
+ });
+
+ this.opts.onMove.call(this, top, left);
+ }
+
+};
+
+/**
+ * Hide
+ */
+ZoomOdoo.prototype.hide = function () {
+ if (!this.isOpen) return;
+ if (this.opts.beforeHide.call(this) === false) return;
+
+ this.$flyout.detach();
+ this.isOpen = false;
+
+ this.opts.onHide.call(this);
+};
+
+// jQuery plugin wrapper
+$.fn.zoomOdoo = function (options) {
+ return this.each(function () {
+ var api = $.data(this, 'zoomOdoo');
+
+ if (!api) {
+ $.data(this, 'zoomOdoo', new ZoomOdoo(this, options));
+ } else if (api.isOpen === undefined) {
+ api._init();
+ }
+ });
+};
+});