odoo.define('web_editor.widget', function (require) { 'use strict'; var ajax = require('web.ajax'); var base = require('web_editor.base'); var core = require('web.core'); var DialogBase = require('web.Dialog'); var Widget = require('web.Widget'); var weContext = require("web_editor.context"); var QWeb = core.qweb; var range = $.summernote.core.range; var dom = $.summernote.core.dom; var _t = core._t; /** * @todo we should either get rid of this or move it somewhere else */ function simulateMouseEvent(el, type) { var evt = document.createEvent("MouseEvents"); evt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el); el.dispatchEvent(evt); } /** * Extend Dialog class to handle save/cancel of edition components. */ var Dialog = DialogBase.extend({ /** * @constructor */ init: function (parent, options) { options = options || {}; this._super(parent, _.extend({}, { buttons: [ {text: options.save_text || _t("Save"), classes: 'btn-primary', click: this.save}, {text: _t("Discard"), close: true} ] }, options)); this.destroyAction = 'cancel'; var self = this; this.opened().then(function () { self.$('input:first').focus(); }); this.on('closed', this, function () { this.trigger(this.destroyAction, this.final_data || null); }); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Called when the dialog is saved. Set the destroy action type to "save" * and should set the final_data variable correctly before closing. */ save: function () { this.destroyAction = "save"; this.close(); }, }); /** * Let users change the alt & title of a media. */ var AltDialog = Dialog.extend({ template: 'web_editor.dialog.alt', xmlDependencies: Dialog.prototype.xmlDependencies.concat( ['/web_editor/static/src/xml/editor.xml'] ), /** * @constructor */ init: function (parent, options, $editable, media) { this._super(parent, _.extend({}, { title: _t("Change media description and tooltip") }, options)); this.$editable = $editable; this.media = media; this.alt = ($(this.media).attr('alt') || "").replace(/"/g, '"'); this.tag_title = ($(this.media).attr('title') || "").replace(/"/g, '"'); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ save: function () { var self = this; range.createFromNode(this.media).select(); this.$editable.data('NoteHistory').recordUndo(); var alt = this.$('#alt').val(); var title = this.$('#title').val(); $(this.media).attr('alt', alt ? alt.replace(/"/g, """) : null).attr('title', title ? title.replace(/"/g, """) : null); _.defer(function () { simulateMouseEvent(self.media, 'mouseup'); }); return this._super.apply(this, arguments); }, }); var MediaWidget = Widget.extend({ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], events: { 'input input.o_we_search': '_onSearchInput', }, /** * @constructor */ init: function (parent, media, options) { this._super.apply(this, arguments); this.media = media; this.$media = $(media); this._onSearchInput = _.debounce(this._onSearchInput, 500); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @todo comment */ clear: function () { if (!this.media) { return; } this._clear(); }, /** * @abstract * @param {string} needle * @returns {Deferred} */ search: function (needle) {}, /** * @abstract * @returns {*} */ save: function () {}, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @abstract */ _clear: function () {}, /** * @private */ _replaceMedia: function ($media) { this.$media.replaceWith($media); this.$media = $media; this.media = $media[0]; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onSearchInput: function (ev) { this.search($(ev.currentTarget).val() || ''); this.hasSearched = true; }, }); /** * Let users choose an image, including uploading a new image in odoo. */ var ImageWidget = MediaWidget.extend({ template: 'web_editor.dialog.image', events: _.extend({}, MediaWidget.prototype.events || {}, { 'click .o_upload_media_button': '_onUploadButtonClick', 'click .o_upload_media_button_no_optimization': '_onUploadButtonNoOptimizationClick', 'change input[type=file]': '_onImageSelection', 'click .o_upload_media_url_button': '_onUploadURLButtonClick', 'input input.o_we_url_input': '_onURLInputChange', 'click .existing-attachments [data-src]': '_onImageClick', 'dblclick .existing-attachments [data-src]': '_onImageDblClick', 'click .o_existing_attachment_remove': '_onRemoveClick', 'click .o_load_more': '_onLoadMoreClick', }), IMAGES_PER_ROW: 6, IMAGES_ROWS: 5, /** * @constructor */ init: function (parent, media, options) { this._super.apply(this, arguments); this.imagesRows = this.IMAGES_ROWS; this.IMAGES_DISPLAYED_TOTAL = this.IMAGES_PER_ROW * this.imagesRows; this.options = options; this.accept = options.accept || (options.document ? '*/*' : 'image/*'); if (options.domain) { this.domain = typeof options.domain === 'function' ? options.domain() : options.domain; } else if (options.res_id) { this.domain = ['|', '&', ['res_model', '=', options.res_model], ['res_id', '=', options.res_id], ['res_model', '=', 'ir.ui.view']]; } else { this.domain = [['res_model', '=', 'ir.ui.view']]; } this.multiImages = options.multiImages; this.firstFilters = options.firstFilters || []; this.lastFilters = options.lastFilters || []; this.images = []; }, /** * @override */ willStart: function () { return $.when( this._super.apply(this, arguments), this.search('', true) ); }, /** * @override */ start: function () { var def = this._super.apply(this, arguments); var self = this; this._renderImages(true); var o = { url: null, alt: null, }; if (this.$media.is('img')) { o.url = this.$media.attr('src'); } else if (this.$media.is('a.o_image')) { o.url = this.$media.attr('href').replace(/[?].*/, ''); o.id = +o.url.match(/\/web\/content\/(\d+)/, '')[1]; } if (o.url) { self._toggleImage(_.find(self.records, function (record) { return record.url === o.url;}) || o, true); } return def; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ save: function () { var self = this; if (this.multiImages) { return this.images; } var img = this.images[0]; if (!img) { return this.media; } var def = $.when(); if (!img.access_token) { def = this._rpc({ model: 'ir.attachment', method: 'generate_access_token', args: [[img.id]] }).then(function (access_token) { img.access_token = access_token[0]; }); } return def.then(function () { if (!img.isDocument) { if (img.access_token && self.options.res_model !== 'ir.ui.view') { img.src += _.str.sprintf('?access_token=%s', img.access_token); } if (!self.$media.is('img')) { // Note: by default the images receive the bootstrap opt-in // img-fluid class. We cannot make them all responsive // by design because of libraries and client databases img. self._replaceMedia($('', {class: 'img-fluid o_we_custom_image'})); } self.$media.attr('src', img.src); } else { if (!self.$media.is('a')) { $('.note-control-selection').hide(); self._replaceMedia($('')); } var href = '/web/content/' + img.id + '?'; if (img.access_token && self.options.res_model !== 'ir.ui.view') { href += _.str.sprintf('access_token=%s&', img.access_token); } href += 'unique=' + img.checksum + '&download=true'; self.$media.attr('href', href); self.$media.addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype); } self.$media.attr('alt', img.alt); var style = self.style; if (style) { self.$media.css(style); } if (self.options.onUpload) { // We consider that when selecting an image it is as if we upload it in the html content. self.options.onUpload([img]); } // Remove crop related attributes if (self.$media.attr('data-aspect-ratio')) { var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y']; _.each(attrs, function (attr) { self.$media.removeData(attr); self.$media.removeAttr('data-' + attr); }); } return self.media; }); }, /** * @override */ search: function (needle, noRender) { var self = this; if (!noRender) { this.$('input.o_we_url_input').val('').trigger('input').trigger('change'); } // TODO: Expand this for adding SVG var domain = this.domain.concat(['|', ['mimetype', '=', false], ['mimetype', this.options.document ? 'not in' : 'in', ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png']]]); if (needle && needle.length) { domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]); } domain.push('|', ['datas_fname', '=', false], '!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']); return this._rpc({ model: 'ir.attachment', method: 'search_read', args: [], kwargs: { domain: domain, fields: ['name', 'datas_fname', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'access_token'], order: [{name: 'id', asc: false}], context: weContext.get(), }, }).then(function (records) { self.records = _.chain(records) .filter(function (r) { return (r.type === "binary" || r.url && r.url.length > 0); }) .uniq(function (r) { return (r.url || r.id); }) .sortBy(function (r) { if (_.any(self.firstFilters, function (filter) { var regex = new RegExp(filter, 'i'); return r.name.match(regex) || r.datas_fname && r.datas_fname.match(regex); })) { return -1; } if (_.any(self.lastFilters, function (filter) { var regex = new RegExp(filter, 'i'); return r.name.match(regex) || r.datas_fname && r.datas_fname.match(regex); })) { return 1; } return 0; }) .value(); _.each(self.records, function (record) { record.src = record.url || _.str.sprintf('/web/image/%s/%s', record.id, encodeURI(record.name)); // Name is added for SEO purposes record.isDocument = !(/gif|jpe|jpg|png/.test(record.mimetype)); }); if (!noRender) { self._renderImages(); self._adaptLoadMore(); } }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private */ _adaptLoadMore: function () { var noMoreImgToLoad = this.IMAGES_DISPLAYED_TOTAL >= this.records.length; this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad); this.$('.o_load_done_msg').toggleClass('d-none', !noMoreImgToLoad); }, /** * @override */ _clear: function () { this.media.className = this.media.className.replace(/(^|\s+)((img(\s|$)|img-(?!circle|rounded|thumbnail))[^\s]*)/g, ' '); }, /** * @private */ _highlightSelectedImages: function () { var self = this; this.$('.o_existing_attachment_cell.o_selected').removeClass("o_selected"); var $select = this.$('.o_existing_attachment_cell [data-src]').filter(function () { var $img = $(this); return !!_.find(self.images, function (v) { return (v.url === $img.data("src") || ($img.data("url") && v.url === $img.data("url")) || v.id === $img.data("id")); }); }); $select.closest('.o_existing_attachment_cell').addClass("o_selected"); return $select; }, /** * @private */ _loadMoreImages: function (forceSearch) { this.imagesRows += 2; this.IMAGES_DISPLAYED_TOTAL = this.imagesRows * this.IMAGES_PER_ROW; if (!forceSearch) { this._renderImages(); this._adaptLoadMore(); } else { this.search(this.$('.o_we_search').val() || ''); } }, /** * @private */ _renderImages: function (withEffect) { var self = this; var rows = _(this.records).chain() .slice(0, this.IMAGES_DISPLAYED_TOTAL) .groupBy(function (a, index) { return Math.floor(index / self.IMAGES_PER_ROW); }) .values() .value(); this.$('.form-text').empty(); // Render menu & content this.$('.existing-attachments').replaceWith( QWeb.render('web_editor.dialog.files.existing.content', { rows: rows, isDocument: this.options.document, withEffect: withEffect, }) ); var $divs = this.$('.o_image'); var imageDefs = _.map($divs, function (el) { var $div = $(el); if (/gif|jpe|jpg|png/.test($div.data('mimetype'))) { var $img = $('', { class: 'img-fluid', src: $div.data('url') || $div.data('src'), }); var def = $.Deferred(); $img[0].onload = def.resolve.bind(def); $div.addClass('o_webimage').append($img); return def; } }); if (withEffect) { $.when.apply($, imageDefs).then(function () { _.delay(function () { $divs.removeClass('o_image_loading'); }, 400); }); } this._highlightSelectedImages(); }, /** * @private */ _toggleImage: function (attachment, clearSearch, forceSelect) { if (this.multiImages) { var img = _.select(this.images, function (v) { return v.id === attachment.id; }); if (img.length) { if (!forceSelect) { this.images.splice(this.images.indexOf(img[0]), 1); } } else { this.images.push(attachment); } } else { this.images = [attachment]; } this._highlightSelectedImages(); if (clearSearch) { this.search(''); } }, /** * @private */ _uploadFile: function () { var self = this; /** * @todo file upload cannot be handled with _rpc smoothly. This uses the * form posting in iframe trick to handle the upload. */ var $iframe = this.$('.o_file_upload_iframe'); $iframe.on('load', function () { var iWindow = $iframe[0].contentWindow; var attachments = iWindow.attachments; var error = iWindow.error; self.$('.well > span').remove(); self.$('.well > div').show(); _.each(attachments, function (record) { record.src = record.url || _.str.sprintf('/web/image/%s/%s', record.id, encodeURI(record.name)); // Name is added for SEO purposes record.isDocument = !(/gif|jpe|jpg|png/.test(record.mimetype)); }); if (error || !attachments.length) { _processFile(null, error || !attachments.length); } self.images = attachments; for (var i = 0; i < attachments.length; i++) { _processFile(attachments[i], error); } if (self.options.onUpload) { self.options.onUpload(attachments); } function _processFile(attachment, error) { if (!error) { self._toggleImage(attachment, true); } else { self.$el.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); self.$el.find('.form-text').text(error); } } }); this.$el.submit(); // Empty file input this.$('.o_file_input').val(''); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onImageClick: function (ev, forceSelect) { var $img = $(ev.currentTarget); var attachment = _.find(this.records, function (record) { return record.id === $img.data('id'); }); this._toggleImage(attachment, false, forceSelect); }, /** * @private */ _onImageDblClick: function (ev) { this._onImageClick(ev, true); this.trigger_up('save_request'); }, /** * @private */ _onImageSelection: function () { var $form = this.$('form'); this.$el.addClass('nosave'); $form.removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); $form.find('.form-text').empty(); this.$('.o_upload_media_button').removeClass('btn-danger btn-success'); this._uploadFile(); }, /** * @private */ _onRemoveClick: function (ev) { var self = this; DialogBase.confirm(this, _t("Are you sure you want to delete this file ?"), { confirm_callback: function () { var $helpBlock = this.$('.form-text').empty(); var $a = $(ev.currentTarget); var id = parseInt($a.data('id'), 10); var attachment = _.findWhere(this.records, {id: id}); return self._rpc({ route: '/web_editor/attachment/remove', params: { ids: [id], }, }).then(function (prevented) { if (_.isEmpty(prevented)) { self.records = _.without(self.records, attachment); self.search(''); return; } $helpBlock.replaceWith(QWeb.render('web_editor.dialog.image.existing.error', { views: prevented[id], })); }); } }); }, /** * @private */ _onURLInputChange: function (ev) { var $input = $(ev.currentTarget); var $button = this.$('.o_upload_media_url_button'); var $success = this.$('.o_we_url_success'); var $warning = this.$('.o_we_url_warning'); var $error = this.$('.o_we_url_error'); var inputValue = $input.val(); var emptyValue = (inputValue === ''); var isURL = /^.+\..+$/.test(inputValue); // TODO improve var isImage = _.any(['.gif', '.jpe', '.jpg', '.png'], function (format) { return inputValue.endsWith(format); }); $button.toggleClass('btn-secondary', emptyValue).toggleClass('btn-primary', !emptyValue) .prop('disabled', !isURL); if (!this.options.document) { $button.text((isURL && !isImage) ? _t("Add as document") : _t("Add image")); } $success.toggleClass('d-none', !isURL); $warning.toggleClass('d-none', !isURL || this.options.document || isImage); $error.toggleClass('d-none', emptyValue || isURL); }, /** * @private */ _onUploadButtonClick: function () { this.$('input[type=file]').click(); }, /** * @private */ _onUploadButtonNoOptimizationClick: function () { this.$('input[name="disable_optimization"]').val('1'); this.$('.o_upload_media_button').click(); }, /** * @private */ _onUploadURLButtonClick: function () { this._uploadFile(); }, /** * @private */ _onLoadMoreClick: function () { this._loadMoreImages(); }, /** * @override */ _onSearchInput: function () { this.imagesRows = this.IMAGES_ROWS; this.IMAGES_DISPLAYED_TOTAL = this.IMAGES_PER_ROW * this.imagesRows; this._super.apply(this, arguments); }, }); /** * Let users choose a font awesome icon, support all font awesome loaded in the * css files. */ var IconWidget = MediaWidget.extend({ template: 'web_editor.dialog.font-icons', events: _.extend({}, MediaWidget.prototype.events || {}, { 'click .font-icons-icon': '_onIconClick', 'dblclick .font-icons-icon': '_onIconDblClick', }), /** * @constructor */ init: function (parent, media) { this._super.apply(this, arguments); base.computeFonts(); this.iconsParser = base.fontIcons; this.alias = _.flatten(_.map(this.iconsParser, function (data) { return data.alias; })); }, /** * @override */ start: function () { this.$icons = this.$('.font-icons-icon'); var classes = (this.media && this.media.className || '').split(/\s+/); for (var i = 0; i < classes.length; i++) { var cls = classes[i]; if (_.contains(this.alias, cls)) { this.selectedIcon = cls; this._highlightSelectedIcon(); } } this.nonIconClasses = _.without(classes, this.selectedIcon); return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ save: function () { var style = this.$media.attr('style') || ''; var iconFont = this._getFont(this.selectedIcon) || {base: 'fa', font: ''}; var finalClasses = _.uniq(this.nonIconClasses.concat([iconFont.base, iconFont.font])); if (!this.$media.is('span')) { var $span = $(''); $span.data(this.$media.data()); this._replaceMedia($span); style = style.replace(/\s*width:[^;]+/, ''); } this.$media.attr({ class: _.compact(finalClasses).join(' '), style: style, }); return this.media; }, /** * @override */ search: function (needle) { var iconsParser = this.iconsParser; if (needle && needle.length) { iconsParser = []; _.filter(this.iconsParser, function (data) { var cssData = _.filter(data.cssData, function (cssData) { return _.find(cssData.names, function (alias) { return alias.indexOf(needle) >= 0; }); }); if (cssData.length) { iconsParser.push({ base: data.base, cssData: cssData, }); } }); } this.$('div.font-icons-icons').html( QWeb.render('web_editor.dialog.font-icons.icons', {iconsParser: iconsParser}) ); return $.when(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _clear: function () { this.media.className = this.media.className.replace(/(^|\s)(fa(\s|$)|fa-[^\s]*)/g, ' '); }, /** * @private */ _getFont: function (classNames) { if (!(classNames instanceof Array)) { classNames = (classNames || "").split(/\s+/); } var fontIcon, cssData; for (var k = 0; k < this.iconsParser.length; k++) { fontIcon = this.iconsParser[k]; for (var s = 0; s < fontIcon.cssData.length; s++) { cssData = fontIcon.cssData[s]; if (_.intersection(classNames, cssData.names).length) { return { base: fontIcon.base, parser: fontIcon.parser, font: cssData.names[0], }; } } } return null; }, /** * @private */ _highlightSelectedIcon: function () { var self = this; this.$icons.removeClass('o_selected'); this.$icons.filter(function (i, el) { return _.contains($(el).data('alias').split(','), self.selectedIcon); }).addClass('o_selected'); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onIconClick: function (ev) { ev.preventDefault(); ev.stopPropagation(); this.selectedIcon = $(ev.currentTarget).data('id'); this._highlightSelectedIcon(); }, /** * @private */ _onIconDblClick: function () { this.trigger_up('save_request'); }, }); /** * Let users choose a video, support all summernote video, and embed iframe. */ var VideoWidget = MediaWidget.extend({ template: 'web_editor.dialog.video', events: _.extend({}, MediaWidget.prototype.events || {}, { 'change .o_video_dialog_options input': '_onUpdateVideoOption', 'input textarea#o_video_text': '_onVideoCodeInput', 'change textarea#o_video_text': '_onVideoCodeChange', }), /** * @constructor */ init: function (parent, media) { this._super.apply(this, arguments); this._onVideoCodeInput = _.debounce(this._onVideoCodeInput, 1000); }, /** * @override */ start: function () { this.$content = this.$('.o_video_dialog_iframe'); var $media = $(this.media); if ($media.hasClass('media_iframe_video')) { var src = $media.data('oe-expression') || $media.data('src') || ''; this.$('textarea#o_video_text').val(src); this.$('input#o_video_autoplay').prop('checked', src.indexOf('autoplay=1') >= 0); this.$('input#o_video_hide_controls').prop('checked', src.indexOf('controls=0') >= 0); this.$('input#o_video_loop').prop('checked', src.indexOf('loop=1') >= 0); this.$('input#o_video_hide_fullscreen').prop('checked', src.indexOf('fs=0') >= 0); this.$('input#o_video_hide_yt_logo').prop('checked', src.indexOf('modestbranding=1') >= 0); this.$('input#o_video_hide_dm_logo').prop('checked', src.indexOf('ui-logo=0') >= 0); this.$('input#o_video_hide_dm_share').prop('checked', src.indexOf('sharing-enable=0') >= 0); this._updateVideo(); } return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ save: function () { this._updateVideo(); if (this.$('.o_video_dialog_iframe').is('iframe')) { this._replaceMedia($( '
' + '
 
' + '
 
' + '' + '
' )); } return this.media; }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _clear: function () { if (this.media.dataset.src) { try { delete this.media.dataset.src; } catch (e) { this.media.dataset.src = undefined; } } this.media.className = this.media.className.replace(/(^|\s)media_iframe_video(\s|$)/g, ' '); }, /** * Creates a video node according to the given URL and options. If not * possible, returns an error code. * * @private * @param {string} url * @param {Object} options * @returns {Object} * $video -> the created video jQuery node * type -> the type of the created video * errorCode -> if defined, either '0' for invalid URL or '1' for * unsupported video provider */ _createVideoNode: function (url, options) { options = options || {}; // Video url patterns(youtube, instagram, vimeo, dailymotion, youku, ...) var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/; var ytMatch = url.match(ytRegExp); var insRegExp = /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/; var insMatch = url.match(insRegExp); var vinRegExp = /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/; var vinMatch = url.match(vinRegExp); var vimRegExp = /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/; var vimMatch = url.match(vimRegExp); var dmRegExp = /.+dailymotion.com\/(video|hub|embed)\/([^_]+)[^#]*(#video=([^_&]+))?/; var dmMatch = url.match(dmRegExp); var ykuRegExp = /(.*).youku\.com\/(v_show\/id_|embed\/)(.+)/; var ykuMatch = url.match(ykuRegExp); var $video = $('