summaryrefslogtreecommitdiff
path: root/addons/web_editor/static/src/js/wysiwyg/widgets
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/static/src/js/wysiwyg/widgets
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web_editor/static/src/js/wysiwyg/widgets')
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js62
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js410
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js81
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js213
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js339
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media.js1463
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js279
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js29
8 files changed, 2876 insertions, 0 deletions
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js
new file mode 100644
index 00000000..80f143b6
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js
@@ -0,0 +1,62 @@
+odoo.define('wysiwyg.widgets.AltDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * Let users change the alt & title of a media.
+ */
+var AltDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.alt',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/wysiwyg.xml']
+ ),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, media) {
+ options = options || {};
+ this._super(parent, _.extend({}, {
+ title: _t("Change media description and tooltip")
+ }, options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.media = media;
+ var allEscQuots = /&quot;/g;
+ this.alt = ($(this.media).attr('alt') || "").replace(allEscQuots, '"');
+ var title = $(this.media).attr('title') || $(this.media).data('original-title') || "";
+ this.tag_title = (title).replace(allEscQuots, '"');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var alt = this.$('#alt').val();
+ var title = this.$('#title').val();
+ var allNonEscQuots = /"/g;
+ $(this.media).attr('alt', alt ? alt.replace(allNonEscQuots, "&quot;") : null)
+ .attr('title', title ? title.replace(allNonEscQuots, "&quot;") : null);
+ $(this.media).trigger('content_changed');
+ this.final_data = this.media;
+ return this._super.apply(this, arguments);
+ },
+});
+
+
+return AltDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
new file mode 100644
index 00000000..d00abd1a
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
@@ -0,0 +1,410 @@
+odoo.define('web_editor.ColorPalette', function (require) {
+'use strict';
+
+const ajax = require('web.ajax');
+const core = require('web.core');
+const session = require('web.session');
+const {ColorpickerWidget} = require('web.Colorpicker');
+const Widget = require('web.Widget');
+const summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+const weUtils = require('web_editor.utils');
+
+const qweb = core.qweb;
+
+const ColorPaletteWidget = Widget.extend({
+ // ! for xmlDependencies, see loadDependencies function
+ template: 'web_editor.snippet.option.colorpicker',
+ events: {
+ 'click .o_we_color_btn': '_onColorButtonClick',
+ 'mouseenter .o_we_color_btn': '_onColorButtonEnter',
+ 'mouseleave .o_we_color_btn': '_onColorButtonLeave',
+ 'click .o_we_colorpicker_switch_pane_btn': '_onSwitchPaneButtonClick',
+ },
+ custom_events: {
+ 'colorpicker_select': '_onColorPickerSelect',
+ 'colorpicker_preview': '_onColorPickerPreview',
+ },
+ /**
+ * @override
+ *
+ * @param {Object} [options]
+ * @param {string} [options.selectedColor] The class or css attribute color selected by default.
+ * @param {boolean} [options.resetButton=true] Whether to display or not the reset button.
+ * @param {string[]} [options.excluded=[]] Sections not to display.
+ * @param {string[]} [options.excludeSectionOf] Extra section to exclude: the one containing the named color.
+ * @param {JQuery} [options.$editable=$()] Editable content from which the custom colors are retrieved.
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.summernoteCustomColorsArray = [].concat(...summernoteCustomColors);
+ this.style = window.getComputedStyle(document.documentElement);
+ this.options = _.extend({
+ selectedColor: false,
+ resetButton: true,
+ excluded: [],
+ excludeSectionOf: null,
+ $editable: $(),
+ withCombinations: false,
+ }, options || {});
+
+ this.selectedColor = '';
+ this.resetButton = this.options.resetButton;
+ this.withCombinations = this.options.withCombinations;
+
+ this.trigger_up('request_editable', {callback: val => this.options.$editable = val});
+ },
+ /**
+ * @override
+ */
+ willStart: async function () {
+ await this._super(...arguments);
+ await ColorPaletteWidget.loadDependencies(this);
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ const res = this._super.apply(this, arguments);
+
+ const $colorSection = this.$('.o_colorpicker_sections[data-color-tab="theme-colors"]');
+ const $clpicker = qweb.has_template('web_editor.colorpicker')
+ ? $(qweb.render('web_editor.colorpicker'))
+ : $(`<colorpicker><div class="o_colorpicker_section" data-name="common"></div></colorpicker>`);
+ $clpicker.find('button').addClass('o_we_color_btn');
+ $clpicker.appendTo($colorSection);
+
+ // Remove excluded palettes (note: only hide them to still be able
+ // to remove their related colors on the DOM target)
+ _.each(this.options.excluded, exc => {
+ this.$('[data-name="' + exc + '"]').addClass('d-none');
+ });
+ if (this.options.excludeSectionOf) {
+ this.$('[data-name]:has([data-color="' + this.options.excludeSectionOf + '"])').addClass('d-none');
+ }
+
+ this.el.querySelectorAll('.o_colorpicker_section').forEach(elem => {
+ $(elem).prepend('<div>' + (elem.dataset.display || '') + '</div>');
+ });
+
+ // Render common colors
+ if (!this.options.excluded.includes('common')) {
+ const $commonColorSection = this.$('[data-name="common"]');
+ summernoteCustomColors.forEach((colorRow, i) => {
+ if (i === 0) {
+ return; // Ignore the summernote gray palette and use ours
+ }
+ const $div = $('<div/>', {class: 'clearfix'}).appendTo($commonColorSection);
+ colorRow.forEach(color => {
+ $div.append(this._createColorButton(color, ['o_common_color']));
+ });
+ });
+ }
+
+ // Compute class colors
+ const compatibilityColorNames = ['primary', 'secondary', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'success', 'info', 'warning', 'danger'];
+ this.colorNames = [...compatibilityColorNames];
+ this.colorToColorNames = {};
+ this.el.querySelectorAll('button[data-color]').forEach(elem => {
+ const colorName = elem.dataset.color;
+ const $color = $(elem);
+ const isCCName = weUtils.isColorCombinationName(colorName);
+ if (isCCName) {
+ $color.find('.o_we_cc_preview_wrapper').addClass(`o_cc o_cc${colorName}`);
+ } else {
+ $color.addClass(`bg-${colorName}`);
+ }
+ this.colorNames.push(colorName);
+ if (!isCCName && !elem.classList.contains('d-none')) {
+ const color = weUtils.getCSSVariableValue(colorName, this.style);
+ this.colorToColorNames[color] = colorName;
+ }
+ });
+
+ // Select selected Color and build customColors.
+ // If no color is selected selectedColor is an empty string (transparent is interpreted as no color)
+ if (this.options.selectedColor) {
+ let selectedColor = this.options.selectedColor;
+ if (compatibilityColorNames.includes(selectedColor)) {
+ selectedColor = weUtils.getCSSVariableValue(selectedColor, this.style) || selectedColor;
+ }
+ selectedColor = ColorpickerWidget.normalizeCSSColor(selectedColor);
+ if (selectedColor !== 'rgba(0, 0, 0, 0)') {
+ this.selectedColor = this.colorToColorNames[selectedColor] || selectedColor;
+ }
+ }
+ this._buildCustomColors();
+ this._markSelectedColor();
+
+ // Colorpicker
+ let defaultColor = this.selectedColor;
+ if (defaultColor && !ColorpickerWidget.isCSSColor(defaultColor)) {
+ defaultColor = weUtils.getCSSVariableValue(defaultColor, this.style);
+ }
+ this.colorPicker = new ColorpickerWidget(this, {
+ defaultColor: defaultColor,
+ });
+ await this.colorPicker.prependTo($colorSection);
+
+ // TODO Added as a fix. In master, the widget should probably not be
+ // instantiated at all.
+ if (this.options.excluded.includes('custom')) {
+ this.colorPicker.$el.addClass('d-none');
+ }
+
+ return res;
+ },
+ /**
+ * Return a list of the color names used in the color palette
+ */
+ getColorNames: function () {
+ return this.colorNames;
+ },
+ /**
+ * Sets the currently selected color
+ *
+ * @param {string} color rgb[a]
+ */
+ setSelectedColor: function (color) {
+ this._selectColor({color: color});
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _buildCustomColors: function () {
+ if (this.options.excluded.includes('custom')) {
+ return;
+ }
+ this.el.querySelectorAll('.o_custom_color').forEach(el => el.remove());
+ const existingColors = new Set(this.summernoteCustomColorsArray.concat(
+ Object.keys(this.colorToColorNames)
+ ));
+ this.trigger_up('get_custom_colors', {
+ onSuccess: (colors) => {
+ colors.forEach(color => {
+ this._addCustomColor(existingColors, color);
+ });
+ },
+ });
+ weUtils.getCSSVariableValue('custom-colors', this.style).split(' ').forEach(v => {
+ const color = weUtils.getCSSVariableValue(v.substring(1, v.length - 1), this.style);
+ if (ColorpickerWidget.isCSSColor(color)) {
+ this._addCustomColor(existingColors, color);
+ }
+ });
+ _.each(this.options.$editable.find('[style*="color"]'), el => {
+ for (const colorProp of ['color', 'backgroundColor']) {
+ this._addCustomColor(existingColors, el.style[colorProp]);
+ }
+ });
+ if (this.selectedColor) {
+ this._addCustomColor(existingColors, this.selectedColor);
+ }
+ },
+ /**
+ * Add the color to the custom color section if it is not in the existingColors.
+ *
+ * @param {string[]} existingColors Colors currently in the colorpicker
+ * @param {string} color Color to add to the cuustom colors
+ */
+ _addCustomColor: function (existingColors, color) {
+ if (!color) {
+ return;
+ }
+ if (!ColorpickerWidget.isCSSColor(color)) {
+ color = weUtils.getCSSVariableValue(color, this.style);
+ }
+ const normColor = ColorpickerWidget.normalizeCSSColor(color);
+ if (!existingColors.has(normColor)) {
+ this._addCustomColorButton(normColor);
+ existingColors.add(normColor);
+ }
+ },
+ /**
+ * Add a custom button in the coresponding section.
+ *
+ * @private
+ * @param {string} color
+ * @param {string[]} classes - classes added to the button
+ * @returns {jQuery}
+ */
+ _addCustomColorButton: function (color, classes = []) {
+ classes.push('o_custom_color');
+ const $themeSection = this.$('.o_colorpicker_section[data-name="theme"]');
+ const $button = this._createColorButton(color, classes);
+ return $button.appendTo($themeSection);
+ },
+ /**
+ * Return a color button.
+ *
+ * @param {string} color
+ * @param {string[]} classes - classes added to the button
+ * @returns {jQuery}
+ */
+ _createColorButton: function (color, classes) {
+ return $('<button/>', {
+ class: 'o_we_color_btn ' + classes.join(' '),
+ style: 'background-color:' + color + ';',
+ });
+ },
+ /**
+ * Gets normalized information about a color button.
+ *
+ * @private
+ * @param {HTMLElement} buttonEl
+ * @returns {Object}
+ */
+ _getButtonInfo: function (buttonEl) {
+ const bgColor = buttonEl.style.backgroundColor;
+ return {
+ color: bgColor ? ColorpickerWidget.normalizeCSSColor(bgColor) : buttonEl.dataset.color || '',
+ target: buttonEl,
+ };
+ },
+ /**
+ * Set the selectedColor and trigger an event
+ *
+ * @param {Object} color
+ * @param {string} [eventName]
+ */
+ _selectColor: function (colorInfo, eventName) {
+ this.selectedColor = colorInfo.color = this.colorToColorNames[colorInfo.color] || colorInfo.color;
+ if (eventName) {
+ this.trigger_up(eventName, colorInfo);
+ }
+ this._buildCustomColors();
+ this._markSelectedColor();
+ this.colorPicker.setSelectedColor(colorInfo.color);
+ },
+ /**
+ * Mark the selected color
+ *
+ * @private
+ */
+ _markSelectedColor: function () {
+ this.el.querySelectorAll('button.selected').forEach(el => el.classList.remove('selected'));
+ const selectedButton = this.el.querySelector(`button[data-color="${this.selectedColor}"], button[style*="background-color:${this.selectedColor};"]`);
+ if (selectedButton) {
+ selectedButton.classList.add('selected');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a color button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonClick: function (ev) {
+ const buttonEl = ev.currentTarget;
+ const colorInfo = this._getButtonInfo(buttonEl);
+ this._selectColor(colorInfo, 'color_picked');
+ },
+ /**
+ * Called when a color button is entered.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonEnter: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('color_hover', this._getButtonInfo(ev.currentTarget));
+ },
+ /**
+ * Called when a color button is left the data color is the color currently selected.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonLeave: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('color_leave', {
+ color: this.selectedColor,
+ target: ev.target,
+ });
+ },
+ /**
+ * Called when an update is made on the colorpicker.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPickerPreview: function (ev) {
+ this.trigger_up('color_hover', {
+ color: ev.data.cssColor,
+ target: this.colorPicker.el,
+ });
+ },
+ /**
+ * Called when a color is selected on the colorpicker (mouseup).
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPickerSelect: function (ev) {
+ this._selectColor({
+ color: ev.data.cssColor,
+ target: this.colorPicker.el,
+ }, 'custom_color_picked');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onSwitchPaneButtonClick(ev) {
+ ev.stopPropagation();
+ this.el.querySelectorAll('.o_we_colorpicker_switch_pane_btn').forEach(el => {
+ el.classList.remove('active');
+ });
+ ev.currentTarget.classList.add('active');
+ this.el.querySelectorAll('.o_colorpicker_sections').forEach(el => {
+ el.classList.toggle('d-none', el.dataset.colorTab !== ev.currentTarget.dataset.target);
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Static
+//------------------------------------------------------------------------------
+
+/**
+ * Load ColorPaletteWidget dependencies. This allows to load them without
+ * instantiating the widget itself.
+ *
+ * @static
+ */
+let colorpickerTemplateProm;
+ColorPaletteWidget.loadDependencies = async function (rpcCapableObj) {
+ const proms = [ajax.loadXML('/web_editor/static/src/xml/snippets.xml', qweb)];
+
+ // Public user using the editor may have a colorpalette but with
+ // the default summernote ones.
+ if (!session.is_website_user) {
+ // We can call the colorPalette multiple times but only need 1 rpc
+ if (!colorpickerTemplateProm && !qweb.has_template('web_editor.colorpicker')) {
+ colorpickerTemplateProm = rpcCapableObj._rpc({
+ model: 'ir.ui.view',
+ method: 'read_template',
+ args: ['web_editor.colorpicker'],
+ }).then(template => {
+ return qweb.add_template('<templates>' + template + '</templates>');
+ });
+ }
+ proms.push(colorpickerTemplateProm);
+ }
+
+ return Promise.all(proms);
+};
+
+return {
+ ColorPaletteWidget: ColorPaletteWidget,
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js
new file mode 100644
index 00000000..516aa4be
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js
@@ -0,0 +1,81 @@
+odoo.define('wysiwyg.widgets.Dialog', function (require) {
+'use strict';
+
+var config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+
+var _t = core._t;
+
+/**
+ * Extend Dialog class to handle save/cancel of edition components.
+ */
+var SummernoteDialog = Dialog.extend({
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ this.options = options || {};
+ if (config.device.isMobile) {
+ options.fullscreen = true;
+ }
+ this._super(parent, _.extend({}, {
+ buttons: [{
+ text: this.options.save_text || _t("Save"),
+ classes: 'btn-primary',
+ click: this.save,
+ },
+ {
+ text: _t("Discard"),
+ close: true,
+ }
+ ]
+ }, this.options));
+
+ this.destroyAction = 'cancel';
+
+ var self = this;
+ this.opened(function () {
+ self.$('input:visible:first').focus();
+ self.$el.closest('.modal').addClass('o_web_editor_dialog');
+ self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose);
+ });
+ this.on('closed', this, function () {
+ self._toggleFullScreen();
+ this.trigger(this.destroyAction, this.final_data || null);
+ });
+ },
+ /**
+ * Only use on config.device.isMobile, it's used by mass mailing to allow the dialog opening on fullscreen
+ * @private
+ */
+ _toggleFullScreen: function() {
+ if (config.device.isMobile && !this.hasFullScreen) {
+ $('#iframe_target[isMobile="true"] #web_editor-top-edit .o_fullscreen').click();
+ }
+ },
+ //--------------------------------------------------------------------------
+ // 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();
+ },
+ /**
+ * @override
+ * @returns {*}
+ */
+ open: function() {
+ this.hasFullScreen = $(window.top.document.body).hasClass('o_field_widgetTextHtml_fullscreen');
+ this._toggleFullScreen();
+ return this._super.apply(this, arguments);
+ },
+});
+
+return SummernoteDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
new file mode 100644
index 00000000..27444e06
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
@@ -0,0 +1,213 @@
+odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) {
+'use strict';
+
+const core = require('web.core');
+const Widget = require('web.Widget');
+const {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} = require('web_editor.image_processing');
+
+const _t = core._t;
+
+const ImageCropWidget = Widget.extend({
+ template: ['wysiwyg.widgets.crop'],
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+ events: {
+ 'click.crop_options [data-action]': '_onCropOptionClick',
+ // zoom event is triggered by the cropperjs library when the user zooms.
+ 'zoom': '_onCropZoom',
+ },
+
+ /**
+ * @constructor
+ */
+ init(parent, media) {
+ this._super(...arguments);
+ this.media = media;
+ this.$media = $(media);
+ // Needed for editors in iframes.
+ this.document = media.ownerDocument;
+ // key: ratio identifier, label: displayed to user, value: used by cropper lib
+ this.aspectRatios = {
+ "0/0": {label: _t("Free"), value: 0},
+ "16/9": {label: "16:9", value: 16 / 9},
+ "4/3": {label: "4:3", value: 4 / 3},
+ "1/1": {label: "1:1", value: 1},
+ "2/3": {label: "2:3", value: 2 / 3},
+ };
+ const src = this.media.getAttribute('src');
+ const data = Object.assign({}, media.dataset);
+ this.initialSrc = src;
+ this.aspectRatio = data.aspectRatio || "0/0";
+ this.mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg';
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super.apply(this, arguments);
+ await loadImageInfo(this.media, this._rpc.bind(this));
+ if (this.media.dataset.originalSrc) {
+ this.originalSrc = this.media.dataset.originalSrc;
+ this.originalId = this.media.dataset.originalId;
+ return;
+ }
+ // Couldn't find an attachment: not croppable.
+ this.uncroppable = true;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ if (this.uncroppable) {
+ this.displayNotification({
+ type: 'warning',
+ title: _t("This image is an external image"),
+ message: _t("This type of image is not supported for cropping.<br/>If you want to crop it, please first download it from the original source and upload it in Odoo."),
+ });
+ return this.destroy();
+ }
+ const _super = this._super.bind(this);
+ const $cropperWrapper = this.$('.o_we_cropper_wrapper');
+
+ // Replacing the src with the original's so that the layout is correct.
+ await loadImage(this.originalSrc, this.media);
+ this.$cropperImage = this.$('.o_we_cropper_img');
+ const cropperImage = this.$cropperImage[0];
+ [cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
+
+ // Overlaying the cropper image over the real image
+ const offset = this.$media.offset();
+ offset.left += parseInt(this.$media.css('padding-left'));
+ offset.top += parseInt(this.$media.css('padding-right'));
+ $cropperWrapper.offset(offset);
+
+ await loadImage(this.originalSrc, cropperImage);
+ await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset);
+ core.bus.trigger('deactivate_snippet');
+
+ this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
+ // We use capture so that the handler is called before other editor handlers
+ // like save, such that we can restore the src before a save.
+ this.document.addEventListener('mousedown', this._onDocumentMousedown, {capture: true});
+ return _super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.$cropperImage) {
+ this.$cropperImage.cropper('destroy');
+ this.document.removeEventListener('mousedown', this._onDocumentMousedown, {capture: true});
+ }
+ this.media.setAttribute('src', this.initialSrc);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the DOM image with cropped data and associates required
+ * information for a potential future save (where required cropped data
+ * attachments will be created).
+ *
+ * @private
+ */
+ async _save() {
+ // Mark the media for later creation of cropped attachment
+ this.media.classList.add('o_modified_image_to_save');
+
+ [...cropperDataFields, 'aspectRatio'].forEach(attr => {
+ delete this.media.dataset[attr];
+ const value = this._getAttributeValue(attr);
+ if (value) {
+ this.media.dataset[attr] = value;
+ }
+ });
+ delete this.media.dataset.resizeWidth;
+ this.initialSrc = await applyModifications(this.media);
+ this.$media.trigger('image_cropped');
+ this.destroy();
+ },
+ /**
+ * Returns an attribute's value for saving.
+ *
+ * @private
+ */
+ _getAttributeValue(attr) {
+ if (cropperDataFields.includes(attr)) {
+ return this.$cropperImage.cropper('getData')[attr];
+ }
+ return this[attr];
+ },
+ /**
+ * Resets the crop box to prevent it going outside the image.
+ *
+ * @private
+ */
+ _resetCropBox() {
+ this.$cropperImage.cropper('clear');
+ this.$cropperImage.cropper('crop');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a crop option is clicked -> change the crop area accordingly.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCropOptionClick(ev) {
+ const {action, value, scaleDirection} = ev.currentTarget.dataset;
+ switch (action) {
+ case 'ratio':
+ this.$cropperImage.cropper('reset');
+ this.aspectRatio = value;
+ this.$cropperImage.cropper('setAspectRatio', this.aspectRatios[this.aspectRatio].value);
+ break;
+ case 'zoom':
+ case 'reset':
+ this.$cropperImage.cropper(action, value);
+ break;
+ case 'rotate':
+ this.$cropperImage.cropper(action, value);
+ this._resetCropBox();
+ break;
+ case 'flip': {
+ const amount = this.$cropperImage.cropper('getData')[scaleDirection] * -1;
+ return this.$cropperImage.cropper(scaleDirection, amount);
+ }
+ case 'apply':
+ return this._save();
+ case 'discard':
+ return this.destroy();
+ }
+ },
+ /**
+ * Discards crop if the user clicks outside of the widget.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDocumentMousedown(ev) {
+ if (document.body.contains(ev.target) && this.$(ev.target).length === 0) {
+ return this.destroy();
+ }
+ },
+ /**
+ * Resets the cropbox on zoom to prevent crop box overflowing.
+ *
+ * @private
+ */
+ async _onCropZoom() {
+ // Wait for the zoom event to be fully processed before reseting.
+ await new Promise(res => setTimeout(res, 0));
+ this._resetCropBox();
+ },
+});
+
+return ImageCropWidget;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
new file mode 100644
index 00000000..2a18ba2b
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
@@ -0,0 +1,339 @@
+odoo.define('wysiwyg.widgets.LinkDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var dom = $.summernote.core.dom;
+var range = $.summernote.core.range;
+
+var _t = core._t;
+
+/**
+ * Allows to customize link content and style.
+ */
+var LinkDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.link',
+ xmlDependencies: (Dialog.prototype.xmlDependencies || []).concat([
+ '/web_editor/static/src/xml/wysiwyg.xml'
+ ]),
+ events: _.extend({}, Dialog.prototype.events || {}, {
+ 'input': '_onAnyChange',
+ 'change [name="link_style_color"]': '_onTypeChange',
+ 'change': '_onAnyChange',
+ 'input input[name="url"]': '_onURLInput',
+ }),
+
+ /**
+ * @constructor
+ * @param {Boolean} linkInfo.isButton - whether if the target is a button element.
+ */
+ init: function (parent, options, editable, linkInfo) {
+ this.options = options || {};
+ this._super(parent, _.extend({
+ title: _t("Link to"),
+ }, this.options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: this.options,
+ callback: recordInfo => {
+ _.defaults(this.options, recordInfo);
+ },
+ });
+
+ this.data = linkInfo || {};
+ this.isButton = this.data.isButton;
+ // Using explicit type 'link' to preserve style when the target is <button class="...btn-link"/>.
+ this.colorsData = [
+ {type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'},
+ {type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
+ {type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
+ // Note: by compatibility the dialog should be able to remove old
+ // colors that were suggested like the BS status colors or the
+ // alpha -> epsilon classes. This is currently done by removing
+ // all btn-* classes anyway.
+ ];
+
+ this.editable = editable;
+ this.data.className = "";
+ this.data.iniClassName = "";
+
+ var r = this.data.range;
+ this.needLabel = !r || (r.sc === r.ec && r.so === r.eo);
+
+ if (this.data.range) {
+ const $el = $(this.data.range.sc).filter(this.isButton ? "button" : "a");
+ this.data.iniClassName = $el.attr("class") || "";
+ this.colorCombinationClass = false;
+ let $node = $el;
+ while ($node.length && !$node.is('body')) {
+ const className = $node.attr('class') || '';
+ const m = className.match(/\b(o_cc\d+)\b/g);
+ if (m) {
+ this.colorCombinationClass = m[0];
+ break;
+ }
+ $node = $node.parent();
+ }
+ this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
+
+ var is_link = this.data.range.isOnAnchor();
+
+ var sc = r.sc;
+ var so = r.so;
+ var ec = r.ec;
+ var eo = r.eo;
+
+ var nodes;
+ if (!is_link) {
+ if (sc.tagName) {
+ sc = dom.firstChild(so ? sc.childNodes[so] : sc);
+ so = 0;
+ } else if (so !== sc.textContent.length) {
+ if (sc === ec) {
+ ec = sc = sc.splitText(so);
+ eo -= so;
+ } else {
+ sc = sc.splitText(so);
+ }
+ so = 0;
+ }
+ if (ec.tagName) {
+ ec = dom.lastChild(eo ? ec.childNodes[eo-1] : ec);
+ eo = ec.textContent.length;
+ } else if (eo !== ec.textContent.length) {
+ ec.splitText(eo);
+ }
+
+ nodes = dom.listBetween(sc, ec);
+
+ // browsers can't target a picture or void node
+ if (dom.isVoid(sc) || dom.isImg(sc)) {
+ so = dom.listPrev(sc).length-1;
+ sc = sc.parentNode;
+ }
+ if (dom.isBR(ec)) {
+ eo = dom.listPrev(ec).length-1;
+ ec = ec.parentNode;
+ } else if (dom.isVoid(ec) || dom.isImg(sc)) {
+ eo = dom.listPrev(ec).length;
+ ec = ec.parentNode;
+ }
+
+ this.data.range = range.create(sc, so, ec, eo);
+ $(editable).data("range", this.data.range);
+ this.data.range.select();
+ } else {
+ nodes = dom.ancestor(sc, dom.isAnchor).childNodes;
+ }
+
+ if (dom.isImg(sc) && nodes.indexOf(sc) === -1) {
+ nodes.push(sc);
+ }
+ if (nodes.length > 1 || dom.ancestor(nodes[0], dom.isImg)) {
+ var text = "";
+ this.data.images = [];
+ for (var i=0; i<nodes.length; i++) {
+ if (dom.ancestor(nodes[i], dom.isImg)) {
+ this.data.images.push(dom.ancestor(nodes[i], dom.isImg));
+ text += '[IMG]';
+ } else if (!is_link && nodes[i].nodeType === 1) {
+ // just use text nodes from listBetween
+ } else if (!is_link && i===0) {
+ text += nodes[i].textContent.slice(so, Infinity);
+ } else if (!is_link && i===nodes.length-1) {
+ text += nodes[i].textContent.slice(0, eo);
+ } else {
+ text += nodes[i].textContent;
+ }
+ }
+ this.data.text = text;
+ }
+ }
+
+ this.data.text = this.data.text.replace(/[ \t\r\n]+/g, ' ');
+
+ var allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
+ var allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
+ this.data.className = this.data.iniClassName
+ .replace(allBtnClassSuffixes, ' ')
+ .replace(allBtnShapes, ' ');
+ // 'o_submit' class will force anchor to be handled as a button in linkdialog.
+ if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
+ this.isButton = true;
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.buttonOptsCollapseEl = this.el.querySelector('#o_link_dialog_button_opts_collapse');
+
+ this.$styleInputs = this.$('input.link-style');
+ this.$styleInputs.prop('checked', false).filter('[value=""]').prop('checked', true);
+ if (this.data.iniClassName) {
+ _.each(this.$('input[name="link_style_color"], select[name="link_style_size"] > option, select[name="link_style_shape"] > option'), el => {
+ var $option = $(el);
+ if ($option.val() && this.data.iniClassName.match(new RegExp('(^|btn-| |btn-outline-)' + $option.val()))) {
+ if ($option.is("input")) {
+ $option.prop("checked", true);
+ } else {
+ $option.parent().find('option').removeAttr('selected').removeProp('selected');
+ $option.parent().val($option.val());
+ $option.attr('selected', 'selected').prop('selected', 'selected');
+ }
+ }
+ });
+ }
+ if (this.data.url) {
+ var match = /mailto:(.+)/.exec(this.data.url);
+ this.$('input[name="url"]').val(match ? match[1] : this.data.url);
+ this._onURLInput();
+ }
+
+ this._updateOptionsUI();
+ this._adaptPreview();
+
+ this.$('input:visible:first').focus();
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var data = this._getData();
+ if (data === null) {
+ var $url = this.$('input[name="url"]');
+ $url.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $url.focus();
+ return Promise.reject();
+ }
+ this.data.text = data.label;
+ this.data.url = data.url;
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi;
+ this.data.className = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
+ if (data.classes.replace(allBtnTypes, ' ')) {
+ this.data.style = {
+ 'background-color': '',
+ 'color': '',
+ };
+ }
+ this.data.isNewWindow = data.isNewWindow;
+ this.final_data = this.data;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adapt the link preview to changes.
+ *
+ * @private
+ */
+ _adaptPreview: function () {
+ var data = this._getData();
+ if (data === null) {
+ return;
+ }
+ const attrs = {
+ target: data.isNewWindow ? '_blank' : '',
+ href: data.url && data.url.length ? data.url : '#',
+ class: `${data.classes.replace(/float-\w+/, '')} o_btn_preview`,
+ };
+ this.$("#link-preview").attr(attrs).html((data.label && data.label.length) ? data.label : data.url);
+ },
+ /**
+ * Get the link's data (url, label and styles).
+ *
+ * @private
+ * @returns {Object} {label: String, url: String, classes: String, isNewWindow: Boolean}
+ */
+ _getData: function () {
+ var $url = this.$('input[name="url"]');
+ var url = $url.val();
+ var label = _.escape(this.$('input[name="label"]').val() || url);
+
+ if (label && this.data.images) {
+ for (var i = 0; i < this.data.images.length; i++) {
+ label = label.replace(/\[IMG\]/, this.data.images[i].outerHTML);
+ }
+ }
+
+ if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
+ return null;
+ }
+
+ const type = this.$('input[name="link_style_color"]:checked').val() || '';
+ const size = this.$('select[name="link_style_size"]').val() || '';
+ const shape = this.$('select[name="link_style_shape"]').val() || '';
+ const shapes = shape ? shape.split(',') : [];
+ const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
+ const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
+ const classes = (this.data.className || '') +
+ (type ? (` btn btn-${style}${type}`) : '') +
+ (shapeClasses ? (` ${shapeClasses}`) : '') +
+ (size ? (' btn-' + size) : '');
+ var isNewWindow = this.$('input[name="is_new_window"]').prop('checked');
+ if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
+ url = ('mailto:' + url);
+ } else if (url.indexOf(location.origin) === 0 && this.$('#o_link_dialog_url_strip_domain').prop("checked")) {
+ url = url.slice(location.origin.length);
+ }
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ return {
+ label: label,
+ url: url,
+ classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''),
+ isNewWindow: isNewWindow,
+ };
+ },
+ /**
+ * @private
+ */
+ _updateOptionsUI: function () {
+ const el = this.el.querySelector('[name="link_style_color"]:checked');
+ $(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnyChange: function () {
+ this._adaptPreview();
+ },
+ /**
+ * @private
+ */
+ _onTypeChange() {
+ this._updateOptionsUI();
+ },
+ /**
+ * @private
+ */
+ _onURLInput: function () {
+ var $linkUrlInput = this.$('#o_link_dialog_url_input');
+ $linkUrlInput.closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ let value = $linkUrlInput.val();
+ let isLink = value.indexOf('@') < 0;
+ this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
+ this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
+ },
+});
+
+return LinkDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
new file mode 100644
index 00000000..99ecabb1
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -0,0 +1,1463 @@
+odoo.define('wysiwyg.widgets.media', function (require) {
+'use strict';
+
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var fonts = require('wysiwyg.fonts');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+var session = require('web.session');
+const {removeOnImageChangeAttrs} = require('web_editor.image_processing');
+const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var MediaWidget = Widget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+
+ /**
+ * @constructor
+ * @param {Element} media: the target Element for which we select a media
+ * @param {Object} options: useful parameters such as res_id, res_model,
+ * context, user_id, ...
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this.media = media;
+ this.$media = $(media);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @todo comment
+ */
+ clear: function () {
+ if (!this.media) {
+ return;
+ }
+ this._clear();
+ },
+ /**
+ * Saves the currently configured media on the target media.
+ *
+ * @abstract
+ * @returns {Promise}
+ */
+ save: function () {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ */
+ _clear: function () {},
+});
+
+var SearchableMediaWidget = MediaWidget.extend({
+ events: _.extend({}, MediaWidget.prototype.events || {}, {
+ 'input .o_we_search': '_onSearchInput',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._onSearchInput = _.debounce(this._onSearchInput, 500);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Finds and displays existing attachments related to the target media.
+ *
+ * @abstract
+ * @param {string} needle: only return attachments matching this parameter
+ * @returns {Promise}
+ */
+ search: function (needle) {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Renders thumbnails for the attachments.
+ *
+ * @abstract
+ * @returns {Promise}
+ */
+ _renderThumbnails: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ this.attachments = [];
+ this.search($(ev.currentTarget).val() || '').then(() => this._renderThumbnails());
+ this.hasSearched = true;
+ },
+});
+
+/**
+ * Let users choose a file, including uploading a new file in odoo.
+ */
+var FileWidget = SearchableMediaWidget.extend({
+ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
+ 'click .o_upload_media_button': '_onUploadButtonClick',
+ 'change .o_file_input': '_onFileInputChange',
+ 'click .o_upload_media_url_button': '_onUploadURLButtonClick',
+ 'input .o_we_url_input': '_onURLInputChange',
+ 'click .o_existing_attachment_cell': '_onAttachmentClick',
+ 'click .o_existing_attachment_remove': '_onRemoveClick',
+ 'click .o_load_more': '_onLoadMoreClick',
+ }),
+ existingAttachmentsTemplate: undefined,
+
+ IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'],
+ NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30,
+ MAX_DB_ATTACHMENTS: 5,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this._mutex = new concurrency.Mutex();
+
+ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
+
+ this.options = _.extend({
+ mediaWidth: media && media.parentElement && $(media.parentElement).width(),
+ useMediaLibrary: true,
+ }, options || {});
+
+ this.attachments = [];
+ this.selectedAttachments = [];
+ this.libraryMedia = [];
+ this.selectedMedia = [];
+
+ this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ var self = this;
+ this.$urlInput = this.$('.o_we_url_input');
+ this.$form = this.$('form');
+ this.$fileInput = this.$('.o_file_input');
+ this.$uploadButton = this.$('.o_upload_media_button');
+ this.$addUrlButton = this.$('.o_upload_media_url_button');
+ this.$urlSuccess = this.$('.o_we_url_success');
+ this.$urlWarning = this.$('.o_we_url_warning');
+ this.$urlError = this.$('.o_we_url_error');
+ this.$errorText = this.$('.o_we_error_text');
+
+ // If there is already an attachment on the target, select by default
+ // that attachment if it is among the loaded images.
+ 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];
+ }
+
+ return this.search('').then(async () => {
+ await this._renderThumbnails();
+ if (o.url) {
+ self._selectAttachement(_.find(self.attachments, function (attachment) {
+ return o.url === attachment.image_src;
+ }) || o);
+ }
+ return def;
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Saves the currently selected image on the target media. If new files are
+ * currently being added, delays the save until all files have been added.
+ *
+ * @override
+ */
+ save: function () {
+ return this._mutex.exec(this._save.bind(this));
+ },
+ /**
+ * @override
+ */
+ search: function (needle) {
+ this.needle = needle;
+ return this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
+ },
+ /**
+ * @param {Number} number - the number of attachments to fetch
+ * @param {Number} offset - from which result to start fetching
+ */
+ fetchAttachments: function (number, offset) {
+ return this._rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ args: [],
+ kwargs: {
+ domain: this._getAttachmentsDomain(this.needle),
+ fields: ['name', 'mimetype', 'description', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'],
+ order: [{name: 'id', asc: false}],
+ context: this.options.context,
+ // Try to fetch first record of next page just to know whether there is a next page.
+ limit: number + 1,
+ offset: offset,
+ },
+ }).then(attachments => {
+ this.attachments = this.attachments.slice();
+ Array.prototype.splice.apply(this.attachments, [offset, attachments.length].concat(attachments));
+ });
+ },
+ /**
+ * Computes whether there is content to display in the template.
+ */
+ hasContent() {
+ return this.attachments.length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ this.media.className = this.media.className && this.media.className.replace(/(^|\s+)(o_image)(?=\s|$)/g, ' ');
+ },
+ /**
+ * Returns the domain for attachments used in media dialog.
+ * We look for attachments related to the current document. If there is a value for the model
+ * field, it is used to search attachments, and the attachments from the current document are
+ * filtered to display only user-created documents.
+ * In the case of a wizard such as mail, we have the documents uploaded and those of the model
+ *
+ * @private
+ * @params {string} needle
+ * @returns {Array} "ir.attachment" odoo domain.
+ */
+ _getAttachmentsDomain: function (needle) {
+ var domain = this.options.attachmentIDs && this.options.attachmentIDs.length ? ['|', ['id', 'in', this.options.attachmentIDs]] : [];
+
+ var attachedDocumentDomain = [
+ '&',
+ ['res_model', '=', this.options.res_model],
+ ['res_id', '=', this.options.res_id|0]
+ ];
+ // if the document is not yet created, do not see the documents of other users
+ if (!this.options.res_id) {
+ attachedDocumentDomain.unshift('&');
+ attachedDocumentDomain.push(['create_uid', '=', this.options.user_id]);
+ }
+ if (this.options.data_res_model) {
+ var relatedDomain = ['&',
+ ['res_model', '=', this.options.data_res_model],
+ ['res_id', '=', this.options.data_res_id|0]];
+ if (!this.options.data_res_id) {
+ relatedDomain.unshift('&');
+ relatedDomain.push(['create_uid', '=', session.uid]);
+ }
+ domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain);
+ } else {
+ domain = domain.concat(attachedDocumentDomain);
+ }
+ domain = ['|', ['public', '=', true]].concat(domain);
+ domain = domain.concat(this.options.mimetypeDomain);
+ if (needle && needle.length) {
+ domain.push(['name', 'ilike', needle]);
+ }
+ if (!this.options.useMediaLibrary) {
+ domain.push('|', ['url', '=', false], '!', ['url', '=ilike', '/web_editor/shape/%']);
+ }
+ domain.push('!', ['name', '=like', '%.crop']);
+ domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']);
+ return domain;
+ },
+ /**
+ * @private
+ */
+ _highlightSelected: function () {
+ var self = this;
+ this.$('.o_existing_attachment_cell.o_we_attachment_selected').removeClass("o_we_attachment_selected");
+ _.each(this.selectedAttachments, function (attachment) {
+ self.$('.o_existing_attachment_cell[data-id=' + attachment.id + ']')
+ .addClass("o_we_attachment_selected").css('display', '');
+ });
+ },
+ /**
+ * @private
+ * @param {object} attachment
+ */
+ _handleNewAttachment: function (attachment) {
+ this.attachments = this.attachments.filter(att => att.id !== attachment.id);
+ this.attachments.unshift(attachment);
+ this._renderThumbnails();
+ this._selectAttachement(attachment);
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _loadMoreImages: function (forceSearch) {
+ return this.fetchAttachments(10, this.numberOfAttachmentsToDisplay).then(() => {
+ this.numberOfAttachmentsToDisplay += 10;
+ if (!forceSearch) {
+ this._renderThumbnails();
+ return Promise.resolve();
+ } else {
+ return this.search(this.$('.o_we_search').val() || '');
+ }
+ });
+ },
+ /**
+ * Renders the existing attachments and returns the result as a string.
+ *
+ * @param {Object[]} attachments
+ * @returns {string}
+ */
+ _renderExisting: function (attachments) {
+ return QWeb.render(this.existingAttachmentsTemplate, {
+ attachments: attachments,
+ widget: this,
+ });
+ },
+ /**
+ * @private
+ */
+ _renderThumbnails: function () {
+ var attachments = this.attachments.slice(0, this.numberOfAttachmentsToDisplay);
+
+ // Render menu & content
+ this.$('.o_we_existing_attachments').replaceWith(
+ this._renderExisting(attachments)
+ );
+
+ this._highlightSelected();
+
+ // adapt load more
+ this.$('.o_we_load_more').toggleClass('d-none', !this.hasContent());
+ var noLoadMoreButton = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY >= this.attachments.length;
+ var noMoreImgToLoad = this.numberOfAttachmentsToDisplay >= this.attachments.length;
+ this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
+ this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _save: async function () {
+ // Create all media-library attachments.
+ const toSave = Object.fromEntries(this.selectedMedia.map(media => [
+ media.id, {
+ query: media.query || '',
+ is_dynamic_svg: !!media.isDynamicSVG,
+ }
+ ]));
+ let mediaAttachments = [];
+ if (Object.keys(toSave).length !== 0) {
+ mediaAttachments = await this._rpc({
+ route: '/web_editor/save_library_media',
+ params: {
+ media: toSave,
+ },
+ });
+ }
+ const selected = this.selectedAttachments.concat(mediaAttachments).map(attachment => {
+ // Color-customize dynamic SVGs with the primary theme color
+ if (attachment.image_src && attachment.image_src.startsWith('/web_editor/shape/')) {
+ const colorCustomizedURL = new URL(attachment.image_src, window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ }
+ return attachment;
+ });
+ if (this.options.multiImages) {
+ return selected;
+ }
+
+ const img = selected[0];
+ if (!img || !img.id || this.$media.attr('src') === img.image_src) {
+ return this.media;
+ }
+
+ if (!img.public && !img.access_token) {
+ await this._rpc({
+ model: 'ir.attachment',
+ method: 'generate_access_token',
+ args: [[img.id]]
+ }).then(function (access_token) {
+ img.access_token = access_token[0];
+ });
+ }
+
+ if (img.image_src) {
+ var src = img.image_src;
+ if (!img.public && img.access_token) {
+ src += _.str.sprintf('?access_token=%s', img.access_token);
+ }
+ if (!this.$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.
+ this.$media = $('<img/>', {class: 'img-fluid o_we_custom_image'});
+ this.media = this.$media[0];
+ }
+ this.$media.attr('src', src);
+ } else {
+ if (!this.$media.is('a')) {
+ $('.note-control-selection').hide();
+ this.$media = $('<a/>');
+ this.media = this.$media[0];
+ }
+ var href = '/web/content/' + img.id + '?';
+ if (!img.public && img.access_token) {
+ href += _.str.sprintf('access_token=%s&', img.access_token);
+ }
+ href += 'unique=' + img.checksum + '&download=true';
+ this.$media.attr('href', href);
+ this.$media.addClass('o_image').attr('title', img.name);
+ }
+
+ this.$media.attr('alt', img.alt || img.description || '');
+ var style = this.style;
+ if (style) {
+ this.$media.css(style);
+ }
+
+ // Remove image modification attributes
+ removeOnImageChangeAttrs.forEach(attr => {
+ delete this.media.dataset[attr];
+ });
+ // Add mimetype for documents
+ if (!img.image_src) {
+ this.media.dataset.mimetype = img.mimetype;
+ }
+ this.media.classList.remove('o_modified_image_to_save');
+ this.$media.trigger('image_changed');
+ return this.media;
+ },
+ /**
+ * @param {object} attachment
+ * @param {boolean} [save=true] to save the given attachment in the DOM and
+ * and to close the media dialog
+ * @private
+ */
+ _selectAttachement: function (attachment, save, {type = 'attachment'} = {}) {
+ const possibleProps = {
+ 'attachment': 'selectedAttachments',
+ 'media': 'selectedMedia'
+ };
+ const prop = possibleProps[type];
+ if (this.options.multiImages) {
+ // if the clicked attachment is already selected then unselect it
+ // unless it was a save request (then keep the current selection)
+ const index = this[prop].indexOf(attachment);
+ if (index !== -1) {
+ if (!save) {
+ this[prop].splice(index, 1);
+ }
+ } else {
+ // if the clicked attachment is not selected, add it to selected
+ this[prop].push(attachment);
+ }
+ } else {
+ Object.values(possibleProps).forEach(prop => {
+ this[prop] = [];
+ });
+ // select the clicked attachment
+ this[prop] = [attachment];
+ }
+ this._highlightSelected();
+ if (save) {
+ this.trigger_up('save_request');
+ }
+ },
+ /**
+ * Updates the add by URL UI.
+ *
+ * @private
+ * @param {boolean} emptyValue
+ * @param {boolean} isURL
+ * @param {boolean} isImage
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this.$addUrlButton.toggleClass('btn-secondary', emptyValue)
+ .toggleClass('btn-primary', !emptyValue)
+ .prop('disabled', !isURL);
+ this.$urlSuccess.toggleClass('d-none', !isURL);
+ this.$urlError.toggleClass('d-none', emptyValue || isURL);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAttachmentClick: function (ev) {
+ const attachment = ev.currentTarget;
+ const {id: attachmentID, mediaId} = attachment.dataset;
+ if (attachmentID) {
+ const attachment = this.attachments.find(attachment => attachment.id === parseInt(attachmentID));
+ this._selectAttachement(attachment, !this.options.multiImages);
+ } else if (mediaId) {
+ const media = this.libraryMedia.find(media => media.id === parseInt(mediaId));
+ this._selectAttachement(media, !this.options.multiImages, {type: 'media'});
+ }
+ },
+ /**
+ * Handles change of the file input: create attachments with the new files
+ * and open the Preview dialog for each of them. Locks the save button until
+ * all new files have been processed.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _onFileInputChange: function () {
+ return this._mutex.exec(this._addData.bind(this));
+ },
+ /**
+ * Uploads the files that are currently selected on the file input, which
+ * creates new attachments. Then inserts them on the media dialog and
+ * selects them. If multiImages is not set, also triggers up the
+ * save_request event to insert the attachment in the DOM.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async _addData() {
+ let files = this.$fileInput[0].files;
+ if (!files.length) {
+ // Case if the input is emptied, return resolved promise
+ return;
+ }
+
+ var self = this;
+ var uploadMutex = new concurrency.Mutex();
+
+ // Upload the smallest file first to block the user the least possible.
+ files = _.sortBy(files, 'size');
+ _.each(files, function (file) {
+ // Upload one file at a time: no need to parallel as upload is
+ // limited by bandwidth.
+ uploadMutex.exec(function () {
+ return utils.getDataURLFromFile(file).then(function (result) {
+ return self._rpc({
+ route: '/web_editor/attachment/add_data',
+ params: {
+ 'name': file.name,
+ 'data': result.split(',')[1],
+ 'res_id': self.options.res_id,
+ 'res_model': self.options.res_model,
+ 'width': 0,
+ 'quality': 0,
+ },
+ }).then(function (attachment) {
+ self._handleNewAttachment(attachment);
+ });
+ });
+ });
+ });
+
+ return uploadMutex.getUnlockedDef().then(function () {
+ if (!self.options.multiImages && !self.noSave) {
+ self.trigger_up('save_request');
+ }
+ self.noSave = false;
+ });
+ },
+ /**
+ * @private
+ */
+ _onRemoveClick: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), {
+ confirm_callback: function () {
+ var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell');
+ var id = parseInt($a.data('id'), 10);
+ var attachment = _.findWhere(self.attachments, {id: id});
+ return self._rpc({
+ route: '/web_editor/attachment/remove',
+ params: {
+ ids: [id],
+ },
+ }).then(function (prevented) {
+ if (_.isEmpty(prevented)) {
+ self.attachments = _.without(self.attachments, attachment);
+ self.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id);
+ if (!self.attachments.length) {
+ self._renderThumbnails(); //render the message and image if empty
+ } else {
+ $a.closest('.o_existing_attachment_cell').remove();
+ }
+ return;
+ }
+ self.$errorText.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', {
+ views: prevented[id],
+ widget: self,
+ }));
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ */
+ _onURLInputChange: function () {
+ var inputValue = this.$urlInput.val();
+ var emptyValue = (inputValue === '');
+
+ var isURL = /^.+\..+$/.test(inputValue); // TODO improve
+ var isImage = _.any(['.gif', '.jpeg', '.jpe', '.jpg', '.png'], function (format) {
+ return inputValue.endsWith(format);
+ });
+
+ this._updateAddUrlUi(emptyValue, isURL, isImage);
+ },
+ /**
+ * @private
+ */
+ _onUploadButtonClick: function () {
+ this.$fileInput.click();
+ },
+ /**
+ * @private
+ */
+ _onUploadURLButtonClick: function () {
+ if (this.$urlInput.is('.o_we_horizontal_collapse')) {
+ this.$urlInput.removeClass('o_we_horizontal_collapse');
+ this.$addUrlButton.attr('disabled', 'disabled');
+ return;
+ }
+ return this._mutex.exec(this._addUrl.bind(this));
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _addUrl: function () {
+ var self = this;
+ return this._rpc({
+ route: '/web_editor/attachment/add_url',
+ params: {
+ 'url': this.$urlInput.val(),
+ 'res_id': this.options.res_id,
+ 'res_model': this.options.res_model,
+ },
+ }).then(function (attachment) {
+ self.$urlInput.val('');
+ self._onURLInputChange();
+ self._handleNewAttachment(attachment);
+ if (!self.options.multiImages) {
+ self.trigger_up('save_request');
+ }
+ });
+ },
+ /**
+ * @private
+ */
+ _onLoadMoreClick: function () {
+ this._loadMoreImages();
+ },
+ /**
+ * @override
+ */
+ _onSearchInput: function () {
+ this.attachments = [];
+ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
+ this._super.apply(this, arguments);
+ },
+});
+
+/**
+ * Let users choose an image, including uploading a new image in odoo.
+ */
+var ImageWidget = FileWidget.extend({
+ template: 'wysiwyg.widgets.image',
+ existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments',
+ events: Object.assign({}, FileWidget.prototype.events, {
+ 'change input.o_we_show_optimized': '_onShowOptimizedChange',
+ 'change .o_we_search_select': '_onSearchSelect',
+ }),
+ MIN_ROW_HEIGHT: 128,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this.searchService = 'all';
+ options = _.extend({
+ accept: 'image/*',
+ mimetypeDomain: [['mimetype', 'in', this.IMAGE_MIMETYPES]],
+ }, options || {});
+ // Binding so we can add/remove it as an addEventListener
+ this._onAttachmentImageLoad = this._onAttachmentImageLoad.bind(this);
+ this._super(parent, media, options);
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ this.el.addEventListener('load', this._onAttachmentImageLoad, true);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.el.removeEventListener('load', this._onAttachmentImageLoad, true);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async fetchAttachments(number, offset) {
+ if (this.needle && this.searchService !== 'database') {
+ number = this.MAX_DB_ATTACHMENTS;
+ offset = 0;
+ }
+ const result = await this._super(number, offset);
+ // Color-substitution for dynamic SVG attachment
+ const primaryColor = getCSSVariableValue('o-color-1');
+ this.attachments.forEach(attachment => {
+ if (attachment.image_src.startsWith('/')) {
+ const newURL = new URL(attachment.image_src, window.location.origin);
+ // Set the main color of dynamic SVGs to o-color-1
+ if (attachment.image_src.startsWith('/web_editor/shape/')) {
+ newURL.searchParams.set('c1', primaryColor);
+ } else {
+ // Set height so that db images load faster
+ newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT);
+ }
+ attachment.thumbnail_src = newURL.pathname + newURL.search;
+ }
+ });
+ if (this.needle && this.options.useMediaLibrary) {
+ try {
+ const response = await this._rpc({
+ route: '/web_editor/media_library_search',
+ params: {
+ 'query': this.needle,
+ 'offset': this.libraryMedia.length,
+ },
+ });
+ const newMedia = response.media;
+ this.nbMediaResults = response.results;
+ this.libraryMedia.push(...newMedia);
+ } catch (e) {
+ // Either API endpoint doesn't exist or is misconfigured.
+ console.error(`Couldn't reach API endpoint.`);
+ }
+ }
+ return result;
+ },
+ /**
+ * @override
+ */
+ hasContent() {
+ if (this.searchService === 'all') {
+ return this._super(...arguments) || this.libraryMedia.length;
+ } else if (this.searchService === 'media-library') {
+ return !!this.libraryMedia.length;
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this._super.apply(this, arguments);
+ this.$addUrlButton.text((isURL && !isImage) ? _t("Add as document") : _t("Add image"));
+ const warning = isURL && !isImage;
+ this.$urlWarning.toggleClass('d-none', !warning);
+ if (warning) {
+ this.$urlSuccess.addClass('d-none');
+ }
+ },
+ /**
+ * @override
+ */
+ _renderThumbnails: function () {
+ const alreadyLoaded = this.$('.o_existing_attachment_cell[data-loaded="true"]');
+ this._super(...arguments);
+ // Hide images until they're loaded
+ this.$('.o_existing_attachment_cell').addClass('d-none');
+ // Replace images that had been previously loaded if any to prevent scroll resetting to top
+ alreadyLoaded.each((index, el) => {
+ const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"], .o_existing_attachment_cell[data-media-id="${el.dataset.mediaId}"]`);
+ if (toReplace.length) {
+ toReplace.replaceWith(el);
+ }
+ });
+ this._toggleOptimized(this.$('input.o_we_show_optimized')[0].checked);
+ // Placeholders have a 3:2 aspect ratio like most photos.
+ const placeholderWidth = 3 / 2 * this.MIN_ROW_HEIGHT;
+ this.$('.o_we_attachment_placeholder').css({
+ flexGrow: placeholderWidth,
+ flexBasis: placeholderWidth,
+ });
+ if (this.needle && ['media-library', 'all'].includes(this.searchService)) {
+ const noMoreImgToLoad = this.libraryMedia.length === this.nbMediaResults;
+ const noLoadMoreButton = noMoreImgToLoad && this.libraryMedia.length <= 15;
+ this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
+ this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
+ }
+ },
+ /**
+ * @override
+ */
+ _renderExisting: function (attachments) {
+ if (this.needle && this.searchService !== 'database') {
+ attachments = attachments.slice(0, this.MAX_DB_ATTACHMENTS);
+ }
+ return QWeb.render(this.existingAttachmentsTemplate, {
+ attachments: attachments,
+ libraryMedia: this.libraryMedia,
+ widget: this,
+ });
+ },
+ /**
+ * @private
+ *
+ * @param {boolean} value whether to toggle optimized attachments on or off
+ */
+ _toggleOptimized: function (value) {
+ this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important'));
+ },
+ /**
+ * @override
+ */
+ _highlightSelected: function () {
+ this._super(...arguments);
+ this.selectedMedia.forEach(media => {
+ this.$(`.o_existing_attachment_cell[data-media-id=${media.id}]`)
+ .addClass("o_we_attachment_selected");
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onAttachmentImageLoad: async function (ev) {
+ const img = ev.target;
+ const cell = img.closest('.o_existing_attachment_cell');
+ if (!cell) {
+ return;
+ }
+ if (cell.dataset.mediaId && !img.src.startsWith('blob')) {
+ const mediaUrl = img.src;
+ try {
+ const response = await fetch(mediaUrl);
+ if (response.headers.get('content-type') === 'image/svg+xml') {
+ const svg = await response.text();
+ const colorRegex = new RegExp(DEFAULT_PALETTE['1'], 'gi');
+ if (colorRegex.test(svg)) {
+ const fileName = mediaUrl.split('/').pop();
+ const file = new File([svg.replace(colorRegex, getCSSVariableValue('o-color-1'))], fileName, {
+ type: "image/svg+xml",
+ });
+ img.src = URL.createObjectURL(file);
+ const media = this.libraryMedia.find(media => media.id === parseInt(cell.dataset.mediaId));
+ if (media) {
+ media.isDynamicSVG = true;
+ }
+ // We changed the src: wait for the next load event to do the styling
+ return;
+ }
+ }
+ } catch (e) {
+ console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.');
+ }
+ }
+ let aspectRatio = img.naturalWidth / img.naturalHeight;
+ // Special case for SVGs with no instrinsic sizes on firefox
+ // See https://github.com/whatwg/html/issues/3510#issuecomment-369982529
+ if (img.naturalHeight === 0) {
+ img.width = 1000;
+ // Position fixed so that the image doesn't affect layout while rendering
+ img.style.position = 'fixed';
+ // Make invisible so the image doesn't briefly appear on the screen
+ img.style.opacity = '0';
+ // Image needs to be in the DOM for dimensions to be correct after render
+ const originalParent = img.parentElement;
+ document.body.appendChild(img);
+
+ aspectRatio = img.width / img.height;
+ originalParent.appendChild(img);
+ img.removeAttribute('width');
+ img.style.removeProperty('position');
+ img.style.removeProperty('opacity');
+ }
+ const width = aspectRatio * this.MIN_ROW_HEIGHT;
+ cell.style.flexGrow = width;
+ cell.style.flexBasis = `${width}px`;
+ cell.classList.remove('d-none');
+ cell.classList.add('d-flex');
+ cell.dataset.loaded = 'true';
+ },
+ /**
+ * @override
+ */
+ _onShowOptimizedChange: function (ev) {
+ this._toggleOptimized(ev.target.checked);
+ },
+ /**
+ * @override
+ */
+ _onSearchSelect: function (ev) {
+ const {value} = ev.target;
+ this.searchService = value;
+ this.$('.o_we_search').trigger('input');
+ },
+ /**
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ this.libraryMedia = [];
+ this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _clear: function (type) {
+ // Not calling _super: we don't want to call the document widget's _clear method on images
+ var allImgClasses = /(^|\s+)(img|img-\S*|o_we_custom_image|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
+ this.media.className = this.media.className && this.media.className.replace(allImgClasses, ' ');
+ },
+});
+
+
+/**
+ * Let users choose a document, including uploading a new document in odoo.
+ */
+var DocumentWidget = FileWidget.extend({
+ template: 'wysiwyg.widgets.document',
+ existingAttachmentsTemplate: 'wysiwyg.widgets.document.existing.attachments',
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ options = _.extend({
+ accept: '*/*',
+ mimetypeDomain: [['mimetype', 'not in', this.IMAGE_MIMETYPES]],
+ }, options || {});
+ this._super(parent, media, options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this._super.apply(this, arguments);
+ this.$addUrlButton.text((isURL && isImage) ? _t("Add as image") : _t("Add document"));
+ const warning = isURL && isImage;
+ this.$urlWarning.toggleClass('d-none', !warning);
+ if (warning) {
+ this.$urlSuccess.addClass('d-none');
+ }
+ },
+ /**
+ * @override
+ */
+ _getAttachmentsDomain: function (needle) {
+ var domain = this._super.apply(this, arguments);
+ // the assets should not be part of the documents
+ return domain.concat('!', utils.assetsDomain());
+ },
+});
+
+/**
+ * Let users choose a font awesome icon, support all font awesome loaded in the
+ * css files.
+ */
+var IconWidget = SearchableMediaWidget.extend({
+ template: 'wysiwyg.widgets.font-icons',
+ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
+ 'click .font-icons-icon': '_onIconClick',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media) {
+ this._super.apply(this, arguments);
+
+ fonts.computeFonts();
+ this.iconsParser = fonts.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.initialIcon = cls;
+ this._highlightSelectedIcon();
+ }
+ }
+ // Kept for compat in stable, no longer in use: remove in master
+ this.nonIconClasses = _.without(classes, 'media_iframe_video', 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: ''};
+ if (!this.$media.is('span, i')) {
+ var $span = $('<span/>');
+ $span.data(this.$media.data());
+ this.$media = $span;
+ this.media = this.$media[0];
+ style = style.replace(/\s*width:[^;]+/, '');
+ }
+ this.$media.removeClass(this.initialIcon).addClass([iconFont.base, iconFont.font]);
+ this.$media.attr('style', style || null);
+ return Promise.resolve(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('wysiwyg.widgets.font-icons.icons', {iconsParser: iconsParser, widget: this})
+ );
+ return Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ var allFaClasses = /(^|\s)(fa|(text-|bg-|fa-)\S*|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
+ this.media.className = this.media.className && this.media.className.replace(allFaClasses, ' ');
+ },
+ /**
+ * @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_we_attachment_selected');
+ this.$icons.filter(function (i, el) {
+ return _.contains($(el).data('alias').split(','), self.selectedIcon);
+ }).addClass('o_we_attachment_selected');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onIconClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.selectedIcon = $(ev.currentTarget).data('id');
+ this._highlightSelectedIcon();
+ this.trigger_up('save_request');
+ },
+});
+
+/**
+ * Let users choose a video, support all summernote video, and embed iframe.
+ */
+var VideoWidget = MediaWidget.extend({
+ template: 'wysiwyg.widgets.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, options) {
+ this._super.apply(this, arguments);
+ this.isForBgVideo = !!options.isForBgVideo;
+ this._onVideoCodeInput = _.debounce(this._onVideoCodeInput, 1000);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$content = this.$('.o_video_dialog_iframe');
+
+ if (this.media) {
+ var $media = $(this.media);
+ var src = $media.data('oe-expression') || $media.data('src') || ($media.is('iframe') ? $media.attr('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.isForBgVideo) {
+ return Promise.resolve({bgVideoSrc: this.$content.attr('src')});
+ }
+ if (this.$('.o_video_dialog_iframe').is('iframe')) {
+ this.$media = $(
+ '<div class="media_iframe_video" data-oe-expression="' + this.$content.attr('src') + '">' +
+ '<div class="css_editable_mode_display">&nbsp;</div>' +
+ '<div class="media_iframe_video_size" contenteditable="false">&nbsp;</div>' +
+ '<iframe src="' + this.$content.attr('src') + '" frameborder="0" contenteditable="false" allowfullscreen="allowfullscreen"></iframe>' +
+ '</div>'
+ );
+ this.media = this.$media[0];
+ }
+ return Promise.resolve(this.media);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ if (this.media.dataset.src) {
+ try {
+ delete this.media.dataset.src;
+ } catch (e) {
+ this.media.dataset.src = undefined;
+ }
+ }
+ var allVideoClasses = /(^|\s)media_iframe_video(\s|$)/g;
+ var isVideo = this.media.className && this.media.className.match(allVideoClasses);
+ if (isVideo) {
+ this.media.className = this.media.className.replace(allVideoClasses, ' ');
+ this.media.innerHTML = '';
+ }
+ },
+ /**
+ * 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 || {};
+ const videoData = this._getVideoURLData(url, options);
+ if (videoData.error) {
+ return {errorCode: 0};
+ }
+ if (!videoData.type) {
+ return {errorCode: 1};
+ }
+ const $video = $('<iframe>').width(1280).height(720)
+ .attr('frameborder', 0)
+ .attr('src', videoData.embedURL)
+ .addClass('o_video_dialog_iframe');
+
+ return {$video: $video, type: videoData.type};
+ },
+ /**
+ * Updates the video preview according to video code and enabled options.
+ *
+ * @private
+ */
+ _updateVideo: function () {
+ // Reset the feedback
+ this.$content.empty();
+ this.$('#o_video_form_group').removeClass('o_has_error o_has_success').find('.form-control, .custom-select').removeClass('is-invalid is-valid');
+ this.$('.o_video_dialog_options div').addClass('d-none');
+
+ // Check video code
+ var $textarea = this.$('textarea#o_video_text');
+ var code = $textarea.val().trim();
+ if (!code) {
+ return;
+ }
+
+ // Detect if we have an embed code rather than an URL
+ var embedMatch = code.match(/(src|href)=["']?([^"']+)?/);
+ if (embedMatch && embedMatch[2].length > 0 && embedMatch[2].indexOf('instagram')) {
+ embedMatch[1] = embedMatch[2]; // Instagram embed code is different
+ }
+ var url = embedMatch ? embedMatch[1] : code;
+
+ var query = this._createVideoNode(url, {
+ 'autoplay': this.isForBgVideo || this.$('input#o_video_autoplay').is(':checked'),
+ 'hide_controls': this.isForBgVideo || this.$('input#o_video_hide_controls').is(':checked'),
+ 'loop': this.isForBgVideo || this.$('input#o_video_loop').is(':checked'),
+ 'hide_fullscreen': this.isForBgVideo || this.$('input#o_video_hide_fullscreen').is(':checked'),
+ 'hide_yt_logo': this.isForBgVideo || this.$('input#o_video_hide_yt_logo').is(':checked'),
+ 'hide_dm_logo': this.isForBgVideo || this.$('input#o_video_hide_dm_logo').is(':checked'),
+ 'hide_dm_share': this.isForBgVideo || this.$('input#o_video_hide_dm_share').is(':checked'),
+ });
+
+ var $optBox = this.$('.o_video_dialog_options');
+
+ // Show / Hide preview elements
+ this.$el.find('.o_video_dialog_preview_text, .media_iframe_video_size').add($optBox).toggleClass('d-none', !query.$video);
+ // Toggle validation classes
+ this.$el.find('#o_video_form_group')
+ .toggleClass('o_has_error', !query.$video).find('.form-control, .custom-select').toggleClass('is-invalid', !query.$video)
+ .end()
+ .toggleClass('o_has_success', !!query.$video).find('.form-control, .custom-select').toggleClass('is-valid', !!query.$video);
+
+ // Individually show / hide options base on the video provider
+ $optBox.find('div.o_' + query.type + '_option').removeClass('d-none');
+
+ // Hide the entire options box if no options are available or if the
+ // dialog is opened for a background-video
+ $optBox.toggleClass('d-none', this.isForBgVideo || $optBox.find('div:not(.d-none)').length === 0);
+
+ if (query.type === 'youtube') {
+ // Youtube only: If 'hide controls' is checked, hide 'fullscreen'
+ // and 'youtube logo' options too
+ this.$('input#o_video_hide_fullscreen, input#o_video_hide_yt_logo').closest('div').toggleClass('d-none', this.$('input#o_video_hide_controls').is(':checked'));
+ }
+
+ var $content = query.$video;
+ if (!$content) {
+ switch (query.errorCode) {
+ case 0:
+ $content = $('<div/>', {
+ class: 'alert alert-danger o_video_dialog_iframe mb-2 mt-2',
+ text: _t("The provided url is not valid"),
+ });
+ break;
+ case 1:
+ $content = $('<div/>', {
+ class: 'alert alert-warning o_video_dialog_iframe mb-2 mt-2',
+ text: _t("The provided url does not reference any supported video"),
+ });
+ break;
+ }
+ }
+ this.$content.replaceWith($content);
+ this.$content = $content;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a video option changes -> Updates the video preview.
+ *
+ * @private
+ */
+ _onUpdateVideoOption: function () {
+ this._updateVideo();
+ },
+ /**
+ * Called when the video code (URL / Iframe) change is confirmed -> Updates
+ * the video preview immediately.
+ *
+ * @private
+ */
+ _onVideoCodeChange: function () {
+ this._updateVideo();
+ },
+ /**
+ * Called when the video code (URL / Iframe) changes -> Updates the video
+ * preview (note: this function is automatically debounced).
+ *
+ * @private
+ */
+ _onVideoCodeInput: function () {
+ this._updateVideo();
+ },
+ /**
+ * Parses a URL and returns the provider type and an emebedable URL.
+ *
+ * @private
+ */
+ _getVideoURLData: function (url, options) {
+ if (!url.match(/^(http:\/\/|https:\/\/|\/\/)[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i)) {
+ return {
+ error: true,
+ message: 'The provided url is invalid',
+ };
+ }
+ const regexes = {
+ youtube: /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/,
+ instagram: /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/,
+ vine: /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/,
+ vimeo: /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/,
+ dailymotion: /.+dailymotion.com\/(video|hub|embed)\/([^_?]+)[^#]*(#video=([^_&]+))?/,
+ youku: /(.*).youku\.com\/(v_show\/id_|embed\/)(.+)/,
+ };
+ const matches = _.mapObject(regexes, regex => url.match(regex));
+ const autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0';
+ const controls = options.hide_controls ? '&controls=0' : '';
+ const loop = options.loop ? '&loop=1' : '';
+
+ let embedURL;
+ let type;
+ if (matches.youtube && matches.youtube[2].length === 11) {
+ const fullscreen = options.hide_fullscreen ? '&fs=0' : '';
+ const ytLoop = loop ? loop + `&playlist=${matches.youtube[2]}` : '';
+ const logo = options.hide_yt_logo ? '&modestbranding=1' : '';
+ embedURL = `//www.youtube${matches.youtube[1] || ''}.com/embed/${matches.youtube[2]}${autoplay}&rel=0${ytLoop}${controls}${fullscreen}${logo}`;
+ type = 'youtube';
+ } else if (matches.instagram && matches.instagram[2].length) {
+ embedURL = `//www.instagram.com/p/${matches.instagram[2]}/embed/`;
+ type = 'instagram';
+ } else if (matches.vine && matches.vine[0].length) {
+ embedURL = `${matches.vine[0]}/embed/simple`;
+ type = 'vine';
+ } else if (matches.vimeo && matches.vimeo[3].length) {
+ const vimeoAutoplay = autoplay.replace('mute', 'muted');
+ embedURL = `//player.vimeo.com/video/${matches.vimeo[3]}${vimeoAutoplay}${loop}`;
+ type = 'vimeo';
+ } else if (matches.dailymotion && matches.dailymotion[2].length) {
+ const videoId = matches.dailymotion[2].replace('video/', '');
+ const logo = options.hide_dm_logo ? '&ui-logo=0' : '';
+ const share = options.hide_dm_share ? '&sharing-enable=0' : '';
+ embedURL = `//www.dailymotion.com/embed/video/${videoId}${autoplay}${controls}${logo}${share}`;
+ type = 'dailymotion';
+ } else if (matches.youku && matches.youku[3].length) {
+ const videoId = matches.youku[3].indexOf('.html?') >= 0 ? matches.youku[3].substring(0, matches.youku[3].indexOf('.html?')) : matches.youku[3];
+ embedURL = `//player.youku.com/embed/${videoId}`;
+ type = 'youku';
+ }
+
+ return {type: type, embedURL: embedURL};
+ },
+});
+
+return {
+ MediaWidget: MediaWidget,
+ SearchableMediaWidget: SearchableMediaWidget,
+ FileWidget: FileWidget,
+ ImageWidget: ImageWidget,
+ DocumentWidget: DocumentWidget,
+ IconWidget: IconWidget,
+ VideoWidget: VideoWidget,
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
new file mode 100644
index 00000000..0832aa45
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
@@ -0,0 +1,279 @@
+odoo.define('wysiwyg.widgets.MediaDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var MediaModules = require('wysiwyg.widgets.media');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * Lets the user select a media. The media can be existing or newly uploaded.
+ *
+ * The media can be one of the following types: image, document, video or
+ * font awesome icon (only existing icons).
+ *
+ * The user may change a media into another one depending on the given options.
+ */
+var MediaDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.media',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/wysiwyg.xml']
+ ),
+ events: _.extend({}, Dialog.prototype.events, {
+ 'click #editor-media-image-tab': '_onClickImageTab',
+ 'click #editor-media-document-tab': '_onClickDocumentTab',
+ 'click #editor-media-icon-tab': '_onClickIconTab',
+ 'click #editor-media-video-tab': '_onClickVideoTab',
+ }),
+ custom_events: _.extend({}, Dialog.prototype.custom_events || {}, {
+ save_request: '_onSaveRequest',
+ show_parent_dialog_request: '_onShowRequest',
+ hide_parent_dialog_request: '_onHideRequest',
+ }),
+
+ /**
+ * @constructor
+ * @param {Element} media
+ */
+ init: function (parent, options, media) {
+ var $media = $(media);
+ media = $media[0];
+ this.media = media;
+
+ options = _.extend({}, options);
+ var onlyImages = options.onlyImages || this.multiImages || (media && ($media.parent().data('oeField') === 'image' || $media.parent().data('oeType') === 'image'));
+ options.noDocuments = onlyImages || options.noDocuments;
+ options.noIcons = onlyImages || options.noIcons;
+ options.noVideos = onlyImages || options.noVideos;
+
+ this._super(parent, _.extend({}, {
+ title: _t("Select a Media"),
+ save_text: _t("Add"),
+ }, options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ type: 'media',
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ if (!options.noImages) {
+ this.imageWidget = new MediaModules.ImageWidget(this, media, options);
+ }
+ if (!options.noDocuments) {
+ this.documentWidget = new MediaModules.DocumentWidget(this, media, options);
+ }
+ if (!options.noIcons) {
+ this.iconWidget = new MediaModules.IconWidget(this, media, options);
+ }
+ if (!options.noVideos) {
+ this.videoWidget = new MediaModules.VideoWidget(this, media, options);
+ }
+
+ if (this.imageWidget && $media.is('img')) {
+ this.activeWidget = this.imageWidget;
+ } else if (this.documentWidget && $media.is('a.o_image')) {
+ this.activeWidget = this.documentWidget;
+ } else if (this.videoWidget && $media.is('.media_iframe_video, .o_bg_video_iframe')) {
+ this.activeWidget = this.videoWidget;
+ } else if (this.iconWidget && $media.is('span, i')) {
+ this.activeWidget = this.iconWidget;
+ } else {
+ this.activeWidget = [this.imageWidget, this.documentWidget, this.videoWidget, this.iconWidget].find(w => !!w);
+ }
+ this.initiallyActiveWidget = this.activeWidget;
+ },
+ /**
+ * Adds the appropriate class to the current modal and appends the media
+ * widgets to their respective tabs.
+ *
+ * @override
+ */
+ start: function () {
+ var promises = [this._super.apply(this, arguments)];
+ this.$modal.find('.modal-dialog').addClass('o_select_media_dialog');
+
+ if (this.imageWidget) {
+ promises.push(this.imageWidget.appendTo(this.$("#editor-media-image")));
+ }
+ if (this.documentWidget) {
+ promises.push(this.documentWidget.appendTo(this.$("#editor-media-document")));
+ }
+ if (this.iconWidget) {
+ promises.push(this.iconWidget.appendTo(this.$("#editor-media-icon")));
+ }
+ if (this.videoWidget) {
+ promises.push(this.videoWidget.appendTo(this.$("#editor-media-video")));
+ }
+
+ this.opened(() => this.$('input.o_we_search:visible:first').focus());
+
+ return Promise.all(promises);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the document widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isDocumentActive: function () {
+ return this.activeWidget === this.documentWidget;
+ },
+ /**
+ * Returns whether the icon widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isIconActive: function () {
+ return this.activeWidget === this.iconWidget;
+ },
+ /**
+ * Returns whether the image widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isImageActive: function () {
+ return this.activeWidget === this.imageWidget;
+ },
+ /**
+ * Returns whether the video widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isVideoActive: function () {
+ return this.activeWidget === this.videoWidget;
+ },
+ /**
+ * Saves the currently selected media from the currently active widget.
+ *
+ * The save event data `final_data` will be one Element in general, but it
+ * will be an Array of Element if `multiImages` is set.
+ *
+ * @override
+ */
+ save: function () {
+ var self = this;
+ var _super = this._super;
+ var args = arguments;
+ return this.activeWidget.save().then(function (data) {
+ if (self.activeWidget !== self.initiallyActiveWidget) {
+ self._clearWidgets();
+ }
+ // Restore classes if the media was replaced (when changing type)
+ if (self.media !== data) {
+ var oldClasses = self.media && _.toArray(self.media.classList);
+ if (oldClasses) {
+ data.className = _.union(_.toArray(data.classList), oldClasses).join(' ');
+ }
+ }
+ self.final_data = data;
+ _super.apply(self, args);
+ $(data).trigger('content_changed');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Call clear on all the widgets except the activeWidget.
+ * We clear because every widgets are modifying the "media" element.
+ * All widget have the responsibility to clear a previous element that
+ * was created from them.
+ */
+ _clearWidgets: function () {
+ [ this.imageWidget,
+ this.documentWidget,
+ this.iconWidget,
+ this.videoWidget
+ ].forEach( (widget) => {
+ if (widget !== this.activeWidget) {
+ widget && widget.clear();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the document widget as the active widget.
+ *
+ * @private
+ */
+ _onClickDocumentTab: function () {
+ this.activeWidget = this.documentWidget;
+ },
+ /**
+ * Sets the icon widget as the active widget.
+ *
+ * @private
+ */
+ _onClickIconTab: function () {
+ this.activeWidget = this.iconWidget;
+ },
+ /**
+ * Sets the image widget as the active widget.
+ *
+ * @private
+ */
+ _onClickImageTab: function () {
+ this.activeWidget = this.imageWidget;
+ },
+ /**
+ * Sets the video widget as the active widget.
+ *
+ * @private
+ */
+ _onClickVideoTab: function () {
+ this.activeWidget = this.videoWidget;
+ },
+ /**
+ * Handles hide request from child widgets.
+ *
+ * This is for usability, to allow hiding the modal for example when another
+ * smaller modal would be displayed on top.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onHideRequest: function (ev) {
+ this.$modal.addClass('d-none');
+ },
+ /**
+ * Handles save request from the child widgets.
+ *
+ * This is for usability, to allow the user to save from other ways than
+ * click on the modal button, such as double clicking a media to select it.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSaveRequest: function (ev) {
+ ev.stopPropagation();
+ this.save();
+ },
+ /**
+ * Handles show request from the child widgets.
+ *
+ * This is for usability, it is the counterpart of @see _onHideRequest.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onShowRequest: function (ev) {
+ this.$modal.removeClass('d-none');
+ },
+});
+
+return MediaDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
new file mode 100644
index 00000000..64a9dc06
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
@@ -0,0 +1,29 @@
+odoo.define('wysiwyg.widgets', function (require) {
+'use strict';
+
+var Dialog = require('wysiwyg.widgets.Dialog');
+var AltDialog = require('wysiwyg.widgets.AltDialog');
+var MediaDialog = require('wysiwyg.widgets.MediaDialog');
+var LinkDialog = require('wysiwyg.widgets.LinkDialog');
+var ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
+const {ColorpickerDialog} = require('web.Colorpicker');
+
+var media = require('wysiwyg.widgets.media');
+
+return {
+ Dialog: Dialog,
+ AltDialog: AltDialog,
+ MediaDialog: MediaDialog,
+ LinkDialog: LinkDialog,
+ ImageCropWidget: ImageCropWidget,
+ ColorpickerDialog: ColorpickerDialog,
+
+ MediaWidget: media.MediaWidget,
+ SearchableMediaWidget: media.SearchableMediaWidget,
+ FileWidget: media.FileWidget,
+ ImageWidget: media.ImageWidget,
+ DocumentWidget: media.DocumentWidget,
+ IconWidget: media.IconWidget,
+ VideoWidget: media.VideoWidget,
+};
+});