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 Dialog = 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. */ Dialog = Dialog.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.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'], /** * @constructor */ init: function (parent, media, options) { this._super.apply(this, arguments); this.media = media; this.$media = $(media); this.page = 0; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @todo comment */ clear: function () { if (!this.media) { return; } this._clear(); }, /** * @todo comment */ getControlPanelConfig: function () { return { searchEnabled: true, pagerEnabled: true, pagerLeftEnabled: false, pagerRightEnabled: false, }; }, /** * @override */ goToPage: function (page) { this.page = page; }, /** * @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]; }, }); /** * Let users choose an image, including uploading a new image in odoo. */ var ImageWidget = MediaWidget.extend({ template: 'web_editor.dialog.image', events: { 'click .o_upload_image_button': '_onUploadButtonClick', 'click .o_upload_image_button_no_optimization': '_onUploadButtonNoOptimizationClick', 'change input[type=file]': '_onImageSelection', 'click .o_upload_image_url_button': '_onUploadURLButtonClick', 'input input.url': "_onSearchInput", 'click .existing-attachments [data-src]': '_onImageClick', 'dblclick .existing-attachments [data-src]': '_onImageDblClick', 'click .o_existing_attachment_remove': '_onRemoveClick', }, IMAGES_PER_ROW: 6, IMAGES_ROWS: 2, /** * @constructor */ init: function (parent, media, options) { this._super.apply(this, arguments); this.IMAGES_PER_PAGE = this.IMAGES_PER_ROW * this.IMAGES_ROWS; 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(); 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 */ getControlPanelConfig: function () { return _.extend(this._super.apply(this, arguments), { pagerLeftEnabled: this.page > 0, pagerRightEnabled: (this.page + 1) * this.IMAGES_PER_PAGE < this.records.length, }); }, /** * @override */ goToPage: function (page) { this._super.apply(this, arguments); this._renderImages(); }, /** * @override */ save: function () { var self = this; if (this.multiImages) { return this.images; } var img = this.images[0]; 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-responsive class. We cannot make them all responsive // by design because of libraries and client databases img. self._replaceMedia($('', {class: 'img-responsive 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.url').val('').trigger('input').trigger('change'); } 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', '=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(); } }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @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 */ _renderImages: function () { var self = this; var startIndex = this.page * this.IMAGES_PER_PAGE; var rows = _(this.records).chain() .slice(startIndex, startIndex + this.IMAGES_PER_PAGE) .groupBy(function (a, index) { return Math.floor(index / self.IMAGES_PER_ROW); }) .values() .value(); this.$('.help-block').empty(); this.$('.existing-attachments').replaceWith(QWeb.render('web_editor.dialog.image.existing.content', {rows: rows})); 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-responsive', 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; } }); $.when.apply($, imageDefs).then(function () { _.delay(function () { $divs.removeClass('o_image_loading'); }, 400); }); this._highlightSelectedImages(); }, /** * @private */ _toggleImage: function (attachment, clearSearch, force_select) { if (this.multiImages) { var img = _.select(this.images, function (v) { return v.id === attachment.id; }); if (img.length) { if (!force_select) { 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 */ _uploadImage: 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.$('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 || '/web/image/' + record.id; 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) { var $button = self.$('.o_upload_image_button'); if (!error) { $button.addClass('btn-success'); self._toggleImage(attachment, true); } else { $button.addClass('btn-danger'); self.$el.addClass('has-error').find('.help-block').text(error); } if (!self.multiImages) { self.trigger_up('save_request'); } } }); this.$el.submit(); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onImageClick: function (ev, force_select) { var $img = $(ev.currentTarget); var attachment = _.find(this.records, function (record) { return record.id === $img.data('id'); }); this._toggleImage(attachment, false, force_select); }, /** * @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('has-error').find('.help-block').empty(); this.$('.o_upload_image_button').removeClass('btn-danger btn-success'); this._uploadImage(); }, /** * @private */ _onRemoveClick: function (ev) { var self = this; var $helpBlock = this.$('.help-block').empty(); var $a = $(ev.currentTarget); var id = parseInt($a.data('id'), 10); var attachment = _.findWhere(this.records, {id: id}); return this._rpc({ route: '/web_editor/attachment/remove', params: { ids: [id], }, }).then(function (prevented) { if (_.isEmpty(prevented)) { self.records = _.without(self.records, attachment); self._renderImages(); return; } $helpBlock.replaceWith(QWeb.render('web_editor.dialog.image.existing.error', { views: prevented[id] })); }); }, /** * @private */ _onSearchInput: function (ev) { var $input = $(ev.currentTarget); var $button = $input.next('button'); var emptyValue = ($input.val() === ''); $button.toggleClass('btn-default', emptyValue).toggleClass('btn-primary', !emptyValue) .prop('disabled', emptyValue); }, /** * @private */ _onUploadButtonClick: function () { this.$('input[type=file]').click(); }, /** * @private */ _onUploadButtonNoOptimizationClick: function () { this.$('input[name="disable_optimization"]').val('1'); this.$('.o_upload_image_button').click(); }, /** * @private */ _onUploadURLButtonClick: function () { this._uploadImage(); }, }); /** * 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 : { '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 */ getControlPanelConfig: function () { return _.extend(this._super.apply(this, arguments), { pagerEnabled: false, }); }, /** * @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}) ); }, //-------------------------------------------------------------------------- // 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= 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 */ getControlPanelConfig: function () { return _.extend(this._super.apply(this, arguments), { searchEnabled: false, pagerEnabled: false, }); }, /** * @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 = $('