diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/static/src/js/wysiwyg/widgets | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web_editor/static/src/js/wysiwyg/widgets')
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 = /"/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, """) : null) + .attr('title', title ? title.replace(allNonEscQuots, """) : 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"> </div>' + + '<div class="media_iframe_video_size" contenteditable="false"> </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, +}; +}); |
