odoo.define('web_editor.snippets.options', function (require) { 'use strict'; var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); var weWidgets = require('wysiwyg.widgets'); var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); var qweb = core.qweb; var _t = core._t; /** * Handles a set of options for one snippet. The registry returned by this * module contains the names of the specialized SnippetOption which can be * referenced thanks to the data-js key in the web_editor options template. */ var SnippetOption = Widget.extend({ events: { 'mouseenter': '_onLinkEnter', 'mouseenter we-button': '_onLinkEnter', 'click': '_onLinkClick', 'click we-button': '_onLinkClick', 'mouseleave': '_onMouseleave', 'mouseleave we-button': '_onMouseleave', }, /** * The option `$el` is supposed to be the associated DOM UI element. * The option controls another DOM element: the snippet it * customizes, which can be found at `$target`. Access to the whole edition * overlay is possible with `$overlay` (this is not recommended though). * * @constructor */ init: function (parent, $target, $overlay, data, options) { this._super.apply(this, arguments); this.options = options; this.$target = $target; this.ownerDocument = this.$target[0].ownerDocument; this.$overlay = $overlay; this.data = data; this.__methodNames = []; }, /** * Called when the option is initialized (i.e. the parent edition overlay is * shown for the first time). * * @override */ start: function () { this._setActive(); return this._super.apply(this, arguments); }, /** * Called when the parent edition overlay is covering the associated snippet * (the first time, this follows the call to the @see start method). * * @abstract */ onFocus: function () {}, /** * Called when the parent edition overlay is covering the associated snippet * for the first time, when it is a new snippet dropped from the d&d snippet * menu. Note: this is called after the start and onFocus methods. * * @abstract */ onBuilt: function () {}, /** * Called when the parent edition overlay is removed from the associated * snippet (another snippet enters edition for example). * * @abstract */ onBlur: function () {}, /** * Called when the associated snippet is the result of the cloning of * another snippet (so `this.$target` is a cloned element). * * @abstract * @param {Object} options * @param {boolean} options.isCurrent * true if the associated snippet is a clone of the main element that * was cloned (so not a clone of a child of this main element that * was cloned) */ onClone: function (options) {}, /** * Called when the associated snippet is moved to another DOM location. * * @abstract */ onMove: function () {}, /** * Called when the associated snippet is about to be removed from the DOM. * * @abstract */ onRemove: function () {}, /** * Called when the template which contains the associated snippet is about * to be saved. * * @abstract */ cleanForSave: function () {}, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Default option method which allows to select one and only one class in * the option classes set and set it on the associated snippet. The common * case is having a sub-collapse with each item having a `data-select-class` * value allowing to choose the associated class. * * @param {boolean|string} previewMode * - truthy if the option is enabled for preview or if leaving it (in * that second case, the value is 'reset') * - false if the option should be activated for good * @param {*} value - the class to activate ($opt.data('selectClass')) * @param {jQuery} $opt - the related DOMElement option */ selectClass: function (previewMode, value, $opt) { var $group = $opt && $opt.parents('we-collapse-area').last(); if (!$group || !$group.length) { $group = this.$el; } var $lis = $group.find('[data-select-class]'); var classes = $lis.map(function () { return $(this).data('selectClass'); }).get().join(' '); this.$target.removeClass(classes); if (value) { this.$target.addClass(value); } }, /** * Default option method which allows to select one or multiple classes in * the option classes set and set it on the associated snippet. The common * case is having a sub-collapse with each item having a `data-toggle-class` * value allowing to toggle the associated class. * * @see this.selectClass */ toggleClass: function (previewMode, value, $opt) { var $lis = this.$el.find('[data-toggle-class]'); var classes = $lis.map(function () { return $(this).data('toggleClass'); }).get().join(' '); var activeClasses = $lis.filter('.active, :has(.active)').map(function () { return $(this).data('toggleClass'); }).get().join(' '); this.$target.removeClass(classes).addClass(activeClasses); if (value && previewMode !== 'reset') { this.$target.toggleClass(value); } }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Override the helper method to search inside the $target element instead * of the UI item element. * * @override */ $: function () { return this.$target.find.apply(this.$target, arguments); }, /** * Sometimes, options may need to notify other options, even in parent * editors. This can be done thanks to the 'option_update' event, which * will then be handled by this function. * * @param {string} name - an identifier for a type of update * @param {*} data */ notify: function (name, data) { if (name === 'target') { this.setTarget(data); } }, /** * Sometimes, an option is binded on an element but should in fact apply on * another one. For example, elements which contain slides: we want all the * per-slide options to be in the main menu of the whole snippet. This * function allows to set the option's target. * * @param {jQuery} $target - the new target element */ setTarget: function ($target) { this.$target = $target; this._setActive(); this.$target.trigger('snippet-option-change', [this]); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Reactivate the options that were activated before previews. */ _reset: function () { var self = this; var $actives = this.$el.find('we-button.active'); _.each($actives, function (activeElement) { var $activeElement = $(activeElement); self.__methodNames = _.without.apply(_, [self.__methodNames].concat(_.keys($activeElement.data()))); self._select('reset', $activeElement); }); _.each(this.__methodNames, function (methodName) { self[methodName]('reset'); }); this.__methodNames = []; }, /** * Activates the option associated to the given DOM element. * * @private * @param {boolean|string} previewMode * - truthy if the option is enabled for preview or if leaving it (in * that second case, the value is 'reset') * - false if the option should be activated for good * @param {jQuery} $opt - the related DOMElement option */ _select: function (previewMode, $opt) { // Options can say they respond to strong choice if (previewMode && ($opt.data('noPreview') || $opt.parent().data('noPreview'))) { return; } // If it is not preview mode, the user selected the option for good // (so record the action) if (!previewMode) { this._reset(); this.trigger_up('request_history_undo_record', {$target: this.$target}); } // Search for methods (data-...) (i.e. data-toggle-class) on the // selected (sub)option and its parents var el = $opt[0]; var methods = []; do { methods.push([el, el.dataset]); el = el.parentNode; } while (this.$el.parent().has(el).length); // Call the found method in the right order (parents -> child) methods.reverse().forEach(data => { var $el = $(data[0]); var methods = data[1]; Object.keys(methods).forEach(methodName => { if (this[methodName]) { if (previewMode === true) { this.__methodNames.push(methodName); } this[methodName](previewMode, methods[methodName], $el); } }); }); this.__methodNames = _.uniq(this.__methodNames); if (!previewMode) { this._setActive(); } this.$target.trigger('content_changed'); }, /** * Tweaks the option DOM elements to show the selected value according to * the state of the $target the option customizes. * * @todo should be extendable in a more easy way * @private */ _setActive: function () { var self = this; this.$el.find('[data-toggle-class]') .removeClass('active') .filter(function () { var className = $(this).data('toggleClass'); return !className || self.$target.hasClass(className); }) .addClass('active'); // Get submenus which are not inside submenus var $submenus = this.$el.find('we-collapse-area') .not('we-collapse-area we-collapse-area'); // Add unique active class for each submenu active item _.each($submenus, function (submenu) { var $elements = $(submenu).find('[data-select-class]'); _processSelectClassElements($elements); }); // Add unique active class for out-of-submenu active item var $externalElements = this.$el.find('[data-select-class]') .not('we-collapse-area *, we-collapse-area'); _processSelectClassElements($externalElements); function _processSelectClassElements($elements) { var maxNbClasses = -1; $elements.removeClass('active') .filter(function () { var className = $(this).data('selectClass'); var nbClasses = className ? className.split(' ').length : 0; if (nbClasses >= maxNbClasses && (!className || self.$target.hasClass(className))) { maxNbClasses = nbClasses; return true; } return false; }) .last() .addClass('active'); } }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Called when a option link is entered -> activates the related option in * preview mode. * * @private * @param {Event} ev */ _onLinkEnter: function (ev) { var $opt = $(ev.target).closest('we-button'); if (!$opt.length) { return; } if (!$opt.is(':hasData')) { return; } this.__click = false; this._select(true, $opt); this.$target.trigger('snippet-option-preview', [this]); }, /** * Called when an option link is clicked -> activates the related option. * * @private * @param {Event} ev */ _onLinkClick: function (ev) { var $opt = $(ev.target).closest('we-button'); if (ev.isDefaultPrevented() || !$opt.length || !$opt.is(':hasData')) { return; } ev.preventDefault(); this.__click = true; this._select(false, $opt); this.$target.trigger('snippet-option-change', [this]); }, /** * Called when an option link/menu is left -> reactivate the options that * were activated before previews. * * @private */ _onMouseleave: function () { if (this.__click) { return; } this._reset(); }, }); //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: /** * The registry object contains the list of available options. */ var registry = {}; registry.sizing = SnippetOption.extend({ /** * @override */ start: function () { var self = this; var def = this._super.apply(this, arguments); this.$handles = this.$overlay.find('.o_handle'); var resizeValues = this._getSize(); this.$handles.on('mousedown', function (ev) { ev.preventDefault(); var $handle = $(ev.currentTarget); var compass = false; var XY = false; if ($handle.hasClass('n')) { compass = 'n'; XY = 'Y'; } else if ($handle.hasClass('s')) { compass = 's'; XY = 'Y'; } else if ($handle.hasClass('e')) { compass = 'e'; XY = 'X'; } else if ($handle.hasClass('w')) { compass = 'w'; XY = 'X'; } var resize = resizeValues[compass]; if (!resize) { return; } var current = 0; var cssProperty = resize[2]; var cssPropertyValue = parseInt(self.$target.css(cssProperty)); _.each(resize[0], function (val, key) { if (self.$target.hasClass(val)) { current = key; } else if (resize[1][key] === cssPropertyValue) { current = key; } }); var begin = current; var beginClass = self.$target.attr('class'); var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); var cursor = $handle.css('cursor') + '-important'; var $body = $(this.ownerDocument.body); $body.addClass(cursor); var xy = ev['page' + XY]; var bodyMouseMove = function (ev) { ev.preventDefault(); var dd = ev['page' + XY] - xy + resize[1][begin]; var next = current + (current + 1 === resize[1].length ? 0 : 1); var prev = current ? (current - 1) : 0; var change = false; if (dd > (2 * resize[1][next] + resize[1][current]) / 3) { self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, '')); self.$target.addClass(resize[0][next]); current = next; change = true; } if (prev !== current && dd < (2 * resize[1][prev] + resize[1][current]) / 3) { self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, '')); self.$target.addClass(resize[0][prev]); current = prev; change = true; } if (change) { self._onResize(compass, beginClass, current); self.trigger_up('cover_update'); $handle.addClass('o_active'); } }; var bodyMouseUp = function () { $body.off('mousemove', bodyMouseMove); $body.off('mouseup', bodyMouseUp); $body.removeClass(cursor); $handle.removeClass('o_active'); // Highlights the previews for a while var $handlers = self.$overlay.find('.o_handle'); $handlers.addClass('o_active').delay(300).queue(function () { $handlers.removeClass('o_active').dequeue(); }); if (begin === current) { return; } setTimeout(function () { self.trigger_up('request_history_undo_record', { $target: self.$target, event: 'resize_' + XY, }); }, 0); }; $body.on('mousemove', bodyMouseMove); $body.on('mouseup', bodyMouseUp); }); return def; }, /** * @override */ onFocus: function () { var resizeValues = this._getSize(); _.each(resizeValues, (value, key) => { this.$handles.filter('.' + key).toggleClass('readonly', !value); }); this._onResize(); }, /** * @override */ onBlur: function () { this.$handles.addClass('readonly'); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ setTarget: function () { this._super.apply(this, arguments); this._onResize(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Returns an object mapping one or several cardinal direction (n, e, s, w) * to an Array containing: * 1) A list of classes to toggle when using this cardinal direction * 2) A list of values these classes are supposed to set on a given CSS prop * 3) The mentioned CSS prop * * Note: this object must also be saved in this.grid before being returned. * * @abstract * @private * @returns {Object} */ _getSize: function () {}, /** * Called when the snippet is being resized and its classes changes. * * @private * @param {string} [compass] - resize direction ('n', 's', 'e' or 'w') * @param {string} [beginClass] - attributes class at the beginning * @param {integer} [current] - current increment in this.grid */ _onResize: function (compass, beginClass, current) { var self = this; // Adapt the resize handles according to the classes and dimensions var resizeValues = this._getSize(); var $handles = this.$overlay.find('.o_handle'); _.each(resizeValues, function (resizeValue, direction) { var classes = resizeValue[0]; var values = resizeValue[1]; var cssProperty = resizeValue[2]; var $handle = $handles.filter('.' + direction); var current = 0; var cssPropertyValue = parseInt(self.$target.css(cssProperty)); _.each(classes, function (className, key) { if (self.$target.hasClass(className)) { current = key; } else if (values[key] === cssPropertyValue) { current = key; } }); $handle.toggleClass('o_handle_start', current === 0); $handle.toggleClass('o_handle_end', current === classes.length - 1); }); // Adapt the handles to fit the left, top and bottom sizes var ml = this.$target.css('margin-left'); this.$overlay.find('.o_handle.w').css({ width: ml, left: '-' + ml, }); this.$overlay.find('.o_handle.e').css({ width: 0, }); _.each(this.$overlay.find(".o_handle.n, .o_handle.s"), function (handle) { var $handle = $(handle); var direction = $handle.hasClass('n') ? 'top': 'bottom'; $handle.height(self.$target.css('padding-' + direction)); }); }, }); /** * Handles the edition of padding-top and padding-bottom. */ registry['sizing_y'] = registry.sizing.extend({ //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _getSize: function () { var nClass = 'pt'; var nProp = 'padding-top'; var sClass = 'pb'; var sProp = 'padding-bottom'; if (this.$target.is('hr')) { nClass = 'mt'; nProp = 'margin-top'; sClass = 'mb'; sProp = 'margin-bottom'; } var grid = []; for (var i = 0; i <= (256 / 8); i++) { grid.push(i * 8); } grid.splice(1, 0, 4); this.grid = { n: [grid.map(v => nClass + v), grid, nProp], s: [grid.map(v => sClass + v), grid, sProp], }; return this.grid; }, }); /** * Handles the edition of snippet's background color classes. */ registry.colorpicker = SnippetOption.extend({ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], events: _.extend({}, SnippetOption.prototype.events || {}, { 'click .colorpicker button': '_onColorButtonClick', 'mouseenter .colorpicker button': '_onColorButtonEnter', 'mouseleave .colorpicker button': '_onColorButtonLeave', 'click .note-color-reset': '_onColorResetButtonClick', }), colorPrefix: 'bg-', /** * @override */ start: function () { var self = this; var res = this._super.apply(this, arguments); if (this.data.colorPrefix) { this.colorPrefix = this.data.colorPrefix; } if (!this.$el.find('.colorpicker').length) { // TODO remove old UI's code that survived var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); var $clpicker = $(qweb.render('web_editor.colorpicker')); _.each($clpicker.find('.o_colorpicker_section'), function (elem) { $(elem).prepend("
" + elem.dataset.display + "
"); }); // Retrieve excluded palettes list var excluded = []; if (this.data.paletteExclude) { excluded = this.data.paletteExclude.replace(/ /g, '').split(','); } // Apply a custom title if specified if (this.data.paletteTitle) { $pt.find('.note-palette-title').text(this.data.paletteTitle); } // Remove excluded palettes _.each(excluded, function (exc) { $clpicker.find('[data-name="' + exc + '"]').remove(); }); // Add common colors to palettes if not excluded if (!('common' in excluded)) { var $commonColorSection = $clpicker.find('[data-name="common"]'); _.each(summernoteCustomColors, function (colorRow, i) { var $div = $('
', {class: 'clearfix'}).appendTo($commonColorSection); if (i === 0) { // Ignore the summernote gray palette and use ours return; } _.each(colorRow, function (color) { $div.append('