odoo.define('web_editor.snippet.editor', 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 Widget = require('web.Widget'); var options = require('web_editor.snippets.options'); var Wysiwyg = require('web_editor.wysiwyg'); const {ColorPaletteWidget} = require('web_editor.ColorPalette'); const SmoothScrollOnDrag = require('web/static/src/js/core/smooth_scroll_on_drag.js'); const {getCSSVariableValue} = require('web_editor.utils'); var _t = core._t; var globalSelector = { closest: () => $(), all: () => $(), is: () => false, }; /** * Management of the overlay and option list for a snippet. */ var SnippetEditor = Widget.extend({ template: 'web_editor.snippet_overlay', xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], events: { 'click .oe_snippet_remove': '_onRemoveClick', 'wheel': '_onMouseWheel', }, custom_events: { 'option_update': '_onOptionUpdate', 'user_value_widget_request': '_onUserValueWidgetRequest', 'snippet_option_update': '_onSnippetOptionUpdate', // TODO remove me in master 'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate', }, layoutElementsSelector: [ '.o_we_shape', '.o_we_bg_filter', ].join(','), /** * @constructor * @param {Widget} parent * @param {Element} target * @param {Object} templateOptions * @param {jQuery} $editable * @param {Object} options */ init: function (parent, target, templateOptions, $editable, options) { this._super.apply(this, arguments); this.options = options; this.$editable = $editable; this.ownerDocument = this.$editable[0].ownerDocument; this.$body = $(this.ownerDocument.body); this.$target = $(target); this.$target.data('snippet-editor', this); this.templateOptions = templateOptions; this.isTargetParentEditable = false; this.isTargetMovable = false; this.$scrollingElement = $().getScrollingElement(); this.__isStarted = new Promise(resolve => { this.__isStartedResolveFunc = resolve; }); }, /** * @override */ start: function () { var defs = [this._super.apply(this, arguments)]; // Initialize the associated options (see snippets.options.js) defs.push(this._initializeOptions()); var $customize = this._customize$Elements[this._customize$Elements.length - 1]; this.isTargetParentEditable = this.$target.parent().is(':o_editable'); this.isTargetMovable = this.isTargetParentEditable && this.isTargetMovable; this.isTargetRemovable = this.isTargetParentEditable && !this.$target.parent().is('[data-oe-type="image"]'); // Initialize move/clone/remove buttons if (this.isTargetMovable) { this.dropped = false; const smoothScrollOptions = this.options.getScrollOptions({ jQueryDraggableOptions: { cursorAt: { left: 10, top: 10 }, handle: '.o_move_handle', helper: () => { var $clone = this.$el.clone().css({width: '24px', height: '24px', border: 0}); $clone.appendTo(this.$body).removeClass('d-none'); return $clone; }, start: this._onDragAndDropStart.bind(this), stop: (...args) => { // Delay our stop handler so that some summernote handlers // which occur on mouseup (and are themself delayed) are // executed first (this prevents the library to crash // because our stop handler may change the DOM). setTimeout(() => { this._onDragAndDropStop(...args); }, 0); }, }, }); this.draggableComponent = new SmoothScrollOnDrag(this, this.$el, $().getScrollingElement(), smoothScrollOptions); } else { this.$('.o_overlay_move_options').addClass('d-none'); $customize.find('.oe_snippet_clone').addClass('d-none'); } if (!this.isTargetRemovable) { this.$el.add($customize).find('.oe_snippet_remove').addClass('d-none'); } var _animationsCount = 0; var postAnimationCover = _.throttle(() => this.cover(), 100); this.$target.on('transitionstart.snippet_editor, animationstart.snippet_editor', () => { // We cannot rely on the fact each transition/animation start will // trigger a transition/animation end as the element may be removed // from the DOM before or it could simply be an infinite animation. // // By simplicity, for each start, we add a delayed operation that // will decrease the animation counter after a fixed duration and // do the post animation cover if none is registered anymore. _animationsCount++; setTimeout(() => { if (!--_animationsCount) { postAnimationCover(); } }, 500); // This delay have to be huge enough to take care of long // animations which will not trigger an animation end event // but if it is too small for some, this is the job of the // animation creator to manually ask for a re-cover }); // On top of what is explained above, do the post animation cover for // each detected transition/animation end so that the user does not see // a flickering when not needed. this.$target.on('transitionend.snippet_editor, animationend.snippet_editor', postAnimationCover); return Promise.all(defs).then(() => { this.__isStartedResolveFunc(this); }); }, /** * @override */ destroy: function () { // Before actually destroying a snippet editor, notify the parent // about it so that it can update its list of alived snippet editors. this.trigger_up('snippet_editor_destroyed'); this._super(...arguments); this.$target.removeData('snippet-editor'); this.$target.off('.snippet_editor'); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Checks whether the snippet options are shown or not. * * @returns {boolean} */ areOptionsShown: function () { const lastIndex = this._customize$Elements.length - 1; return !!this._customize$Elements[lastIndex].parent().length; }, /** * Notifies all the associated snippet options that the snippet has just * been dropped in the page. */ buildSnippet: async function () { for (var i in this.styles) { this.styles[i].onBuilt(); } await this.toggleTargetVisibility(true); }, /** * Notifies all the associated snippet options that the template which * contains the snippet is about to be saved. */ cleanForSave: async function () { if (this.isDestroyed()) { return; } await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible')); const proms = _.map(this.styles, option => { return option.cleanForSave(); }); await Promise.all(proms); }, /** * Closes all widgets of all options. */ closeWidgets: function () { if (!this.styles || !this.areOptionsShown()) { return; } Object.keys(this.styles).forEach(key => { this.styles[key].closeWidgets(); }); }, /** * Makes the editor overlay cover the associated snippet. */ cover: function () { if (!this.isShown() || !this.$target.length) { return; } const $modal = this.$target.find('.modal'); const $target = $modal.length ? $modal : this.$target; const targetEl = $target[0]; // Check first if the target is still visible, otherwise we have to // hide it. When covering all element after scroll for instance it may // have been hidden (part of an affixed header for example) or it may // be outside of the viewport (the whole header during an effect for // example). const rect = targetEl.getBoundingClientRect(); const vpWidth = window.innerWidth || document.documentElement.clientWidth; const vpHeight = window.innerHeight || document.documentElement.clientHeight; const isInViewport = ( rect.bottom > -0.1 && rect.right > -0.1 && (vpHeight - rect.top) > -0.1 && (vpWidth - rect.left) > -0.1 ); const hasSize = ( // :visible not enough for images Math.abs(rect.bottom - rect.top) > 0.01 && Math.abs(rect.right - rect.left) > 0.01 ); if (!isInViewport || !hasSize || !this.$target.is(`:visible`)) { this.toggleOverlayVisibility(false); return; } // Now cover the element const offset = $target.offset(); var manipulatorOffset = this.$el.parent().offset(); offset.top -= manipulatorOffset.top; offset.left -= manipulatorOffset.left; this.$el.css({ width: $target.outerWidth(), left: offset.left, top: offset.top, }); this.$('.o_handles').css('height', $target.outerHeight()); const editableOffsetTop = this.$editable.offset().top - manipulatorOffset.top; this.$el.toggleClass('o_top_cover', offset.top - editableOffsetTop < 25); }, /** * DOMElements have a default name which appears in the overlay when they * are being edited. This method retrieves this name; it can be defined * directly in the DOM thanks to the `data-name` attribute. */ getName: function () { if (this.$target.data('name') !== undefined) { return this.$target.data('name'); } if (this.$target.is('img')) { return _t("Image"); } if (this.$target.parent('.row').length) { return _t("Column"); } return _t("Block"); }, /** * @return {boolean} */ isShown: function () { return this.$el && this.$el.parent().length && this.$el.hasClass('oe_active'); }, /** * @returns {boolean} */ isSticky: function () { return this.$el && this.$el.hasClass('o_we_overlay_sticky'); }, /** * @returns {boolean} */ isTargetVisible: function () { return (this.$target[0].dataset.invisible !== '1'); }, /** * Removes the associated snippet from the DOM and destroys the associated * editor (itself). * * @returns {Promise} */ removeSnippet: async function () { this.toggleOverlay(false); await this.toggleOptions(false); // If it is an invisible element, we must close it before deleting it (e.g. modal) await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible')); await new Promise(resolve => { this.trigger_up('call_for_each_child_snippet', { $snippet: this.$target, callback: function (editor, $snippet) { for (var i in editor.styles) { editor.styles[i].onRemove(); } resolve(); }, }); }); this.trigger_up('go_to_parent', {$snippet: this.$target}); var $parent = this.$target.parent(); this.$target.find('*').addBack().tooltip('dispose'); this.$target.remove(); this.$el.remove(); var node = $parent[0]; if (node && node.firstChild) { if (!node.firstChild.tagName && node.firstChild.textContent === ' ') { node.removeChild(node.firstChild); } } if ($parent.closest(':data("snippet-editor")').length) { const isEmptyAndRemovable = ($el, editor) => { editor = editor || $el.data('snippet-editor'); const isEmpty = $el.text().trim() === '' && $el.children().toArray().every(el => { // Consider layout-only elements (like bg-shapes) as empty return el.matches(this.layoutElementsSelector); }); return isEmpty && !$el.hasClass('oe_structure') && (!editor || editor.isTargetParentEditable); }; var editor = $parent.data('snippet-editor'); while (!editor) { var $nextParent = $parent.parent(); if (isEmptyAndRemovable($parent)) { $parent.remove(); } $parent = $nextParent; editor = $parent.data('snippet-editor'); } if (isEmptyAndRemovable($parent, editor)) { // TODO maybe this should be part of the actual Promise being // returned by the function ? setTimeout(() => editor.removeSnippet()); } } // clean editor if they are image or table in deleted content this.$body.find('.note-control-selection').hide(); this.$body.find('.o_table_handler').remove(); this.trigger_up('snippet_removed'); this.destroy(); $parent.trigger('content_changed'); // TODO Page content changed, some elements may need to be adapted // according to it. While waiting for a better way to handle that this // window trigger will handle most cases. $(window).trigger('resize'); }, /** * Displays/Hides the editor overlay. * * @param {boolean} show * @param {boolean} [previewMode=false] */ toggleOverlay: function (show, previewMode) { if (!this.$el) { return; } if (previewMode) { // In preview mode, the sticky classes are left untouched, we only // add/remove the preview class when toggling/untoggling this.$el.toggleClass('o_we_overlay_preview', show); } else { // In non preview mode, the preview class is always removed, and the // sticky class is added/removed when toggling/untoggling this.$el.removeClass('o_we_overlay_preview'); this.$el.toggleClass('o_we_overlay_sticky', show); } // Show/hide overlay in preview mode or not this.$el.toggleClass('oe_active', show); this.cover(); }, /** * Displays/Hides the editor (+ parent) options and call onFocus/onBlur if * necessary. * * @param {boolean} show * @returns {Promise} */ async toggleOptions(show) { if (!this.$el) { return; } if (this.areOptionsShown() === show) { return; } // TODO should update the panel after the items have been updated this.trigger_up('update_customize_elements', { customize$Elements: show ? this._customize$Elements : [], }); // All onFocus before all ui updates as the onFocus of an option might // affect another option (like updating the $target) const editorUIsToUpdate = []; const focusOrBlur = show ? (editor, options) => { for (const opt of options) { opt.onFocus(); } editorUIsToUpdate.push(editor); } : (editor, options) => { for (const opt of options) { opt.onBlur(); } }; for (const $el of this._customize$Elements) { const editor = $el.data('editor'); const styles = _.chain(editor.styles) .values() .sortBy('__order') .value(); // TODO ideally: allow async parts in onFocus/onBlur focusOrBlur(editor, styles); } await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUI())); await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUIVisibility())); }, /** * @param {boolean} [show] * @returns {Promise} */ toggleTargetVisibility: async function (show) { show = this._toggleVisibilityStatus(show); var styles = _.values(this.styles); const proms = _.sortBy(styles, '__order').map(style => { return show ? style.onTargetShow() : style.onTargetHide(); }); await Promise.all(proms); return show; }, /** * @param {boolean} [show=false] */ toggleOverlayVisibility: function (show) { if (this.$el && !this.scrollingTimeout) { this.$el.toggleClass('o_overlay_hidden', !show && this.isShown()); } }, /** * Updates the UI of all the options according to the status of their * associated editable DOM. This does not take care of options *visibility*. * For that @see updateOptionsUIVisibility, which should called when the UI * is up-to-date thanks to the function here, as the visibility depends on * the UI's status. * * @returns {Promise} */ async updateOptionsUI() { const proms = Object.values(this.styles).map(opt => { return opt.updateUI({noVisibility: true}); }); return Promise.all(proms); }, /** * Updates the visibility of the UI of all the options according to the * status of their associated dependencies and related editable DOM status. * * @returns {Promise} */ async updateOptionsUIVisibility() { const proms = Object.values(this.styles).map(opt => { return opt.updateUIVisibility(); }); return Promise.all(proms); }, /** * Clones the current snippet. * * @private * @param {boolean} recordUndo */ clone: async function (recordUndo) { this.trigger_up('snippet_will_be_cloned', {$target: this.$target}); var $clone = this.$target.clone(false); if (recordUndo) { this.trigger_up('request_history_undo_record', {$target: this.$target}); } this.$target.after($clone); await new Promise(resolve => { this.trigger_up('call_for_each_child_snippet', { $snippet: $clone, callback: function (editor, $snippet) { for (var i in editor.styles) { editor.styles[i].onClone({ isCurrent: ($snippet.is($clone)), }); } resolve(); }, }); }); this.trigger_up('snippet_cloned', {$target: $clone, $origin: this.$target}); $clone.trigger('content_changed'); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Instantiates the snippet's options. * * @private */ _initializeOptions: function () { this._customize$Elements = []; this.styles = {}; this.selectorSiblings = []; this.selectorChildren = []; var $element = this.$target.parent(); while ($element.length) { var parentEditor = $element.data('snippet-editor'); if (parentEditor) { this._customize$Elements = this._customize$Elements .concat(parentEditor._customize$Elements); break; } $element = $element.parent(); } var $optionsSection = $(core.qweb.render('web_editor.customize_block_options_section', { name: this.getName(), })).data('editor', this); const $optionsSectionBtnGroup = $optionsSection.find('we-top-button-group'); $optionsSectionBtnGroup.contents().each((i, node) => { if (node.nodeType === Node.TEXT_NODE) { node.parentNode.removeChild(node); } }); $optionsSection.on('mouseenter', this._onOptionsSectionMouseEnter.bind(this)); $optionsSection.on('mouseleave', this._onOptionsSectionMouseLeave.bind(this)); $optionsSection.on('click', 'we-title > span', this._onOptionsSectionClick.bind(this)); $optionsSection.on('click', '.oe_snippet_clone', this._onCloneClick.bind(this)); $optionsSection.on('click', '.oe_snippet_remove', this._onRemoveClick.bind(this)); this._customize$Elements.push($optionsSection); // TODO get rid of this when possible (made as a fix to support old // theme options) this.$el.data('$optionsSection', $optionsSection); var i = 0; var defs = _.map(this.templateOptions, val => { if (!val.selector.is(this.$target)) { return; } if (val['drop-near']) { this.selectorSiblings.push(val['drop-near']); } if (val['drop-in']) { this.selectorChildren.push(val['drop-in']); } var optionName = val.option; var option = new (options.registry[optionName] || options.Class)( this, val.$el.children(), val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target, this.$el, _.extend({ optionName: optionName, snippetName: this.getName(), }, val.data), this.options ); var key = optionName || _.uniqueId('option'); if (this.styles[key]) { // If two snippet options use the same option name (and so use // the same JS option), store the subsequent ones with a unique // ID (TODO improve) key = _.uniqueId(key); } this.styles[key] = option; option.__order = i++; if (option.forceNoDeleteButton) { this.$el.add($optionsSection).find('.oe_snippet_remove').addClass('d-none'); } return option.appendTo(document.createDocumentFragment()); }); this.isTargetMovable = (this.selectorSiblings.length > 0 || this.selectorChildren.length > 0); this.$el.find('[data-toggle="dropdown"]').dropdown(); return Promise.all(defs).then(() => { const options = _.sortBy(this.styles, '__order'); options.forEach(option => { if (option.isTopOption) { $optionsSectionBtnGroup.prepend(option.$el); } else { $optionsSection.append(option.$el); } }); $optionsSection.toggleClass('d-none', options.length === 0); }); }, /** * @private * @param {boolean} [show] */ _toggleVisibilityStatus: function (show) { if (show === undefined) { show = !this.isTargetVisible(); } if (show) { delete this.$target[0].dataset.invisible; } else { this.$target[0].dataset.invisible = '1'; } return show; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Called when the 'clone' button is clicked. * * @private * @param {Event} ev */ _onCloneClick: function (ev) { ev.preventDefault(); this.clone(true); }, /** * Called when the snippet is starting to be dragged thanks to the 'move' * button. * * @private */ _onDragAndDropStart: function () { var self = this; this.dropped = false; self.size = { width: self.$target.width(), height: self.$target.height() }; self.$target.after('