diff --git a/addons/mass_mailing/static/src/js/mass_mailing_editor.js b/addons/mass_mailing/static/src/js/mass_mailing_editor.js index 593ed6140cd..d133b878aa6 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_editor.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_editor.js @@ -1,14 +1,14 @@ odoo.define('mass_mailing.editor', function (require) { "use strict"; -var ajax = require("web.ajax"); -var core = require("web.core"); +require('web.dom_ready'); +var ajax = require('web.ajax'); +var core = require('web.core'); var rte = require('web_editor.rte'); -var web_editor = require('web_editor.editor'); var options = require('web_editor.snippets.options'); var snippets_editor = require('web_editor.snippet.editor'); -var $editable_area = $("#editable_area"); +var $editable_area = $('#editable_area'); var odoo_top = window.top.odoo; // Snippet option for resizing image and column width inline like excel @@ -34,11 +34,10 @@ options.registry["width-x"] = options.Class.extend({ var $handle = $(this); var compass = false; - _.each(['n', 's', 'e', 'w' ], function(handler) { + _.each(['n', 's', 'e', 'w' ], function (handler) { if ($handle.hasClass(handler)) { compass = handler; } }); if (self.is_image) { compass = "image"; } - self.buildingBlock.editor_busy = true; $body.on("mousemove.mass_mailing_width_x", function (event) { event.preventDefault(); @@ -60,16 +59,11 @@ options.registry["width-x"] = options.Class.extend({ self.change_width(event, self.$target, target_width, offset, true); } }); - $body.on("mouseup.mass_mailing_width_x", function () { - $body.off('.mass_mailing_width_x'); - self.buildingBlock.editor_busy = false; - self.$target.removeClass("resize_editor_busy"); - }); }); }, change_width: function (event, target, target_width, offset, grow) { target.css("width", grow ? (event.pageX - offset) : (offset + target_width - event.pageX)); - this.buildingBlock.cover_target(this.$overlay, this.$target); + this.trigger_up('cover_update'); }, get_int_width: function (el) { return parseInt($(el).css("width"), 10); @@ -77,7 +71,7 @@ options.registry["width-x"] = options.Class.extend({ get_max_width: function ($el) { return this.container_width - _.reduce(_.map($el.siblings(), this.get_int_width), function (memo, w) { return memo + w; }); }, - on_focus: function () { + onFocus: function () { this._super.apply(this, arguments); if (this.$target.is("td, th")) { @@ -87,34 +81,34 @@ options.registry["width-x"] = options.Class.extend({ }); options.registry.table_item = options.Class.extend({ - on_clone: function ($clone) { + onClone: function () { this._super.apply(this, arguments); // If we cloned a td or th element... if (this.$target.is("td, th")) { // ... and that the td or th element was alone on its row ... if (this.$target.siblings().length === 1) { - var $tr = $clone.parent(); - $tr.clone().empty().insertAfter($tr).append($clone); // ... move the clone in a new row instead + var $tr = this.$target.parent(); + $tr.clone().empty().insertAfter($tr).append(this.$target); // ... move the clone in a new row instead return; } // ... if not, if the clone neighbor is an empty cell, remove this empty cell (like if the clone content had been put in that cell) - var $next = $clone.next(); + var $next = this.$target.next(); if ($next.length && $next.text().trim() === "") { $next.remove(); return; } // ... if not, insert an empty col in each other row, at the index of the clone - var width = $clone.width(); + var width = this.$target.width(); var $trs = this.$target.closest("table").children("thead, tbody, tfoot").addBack().children("tr").not(this.$target.parent()); _.each($trs.children(":nth-child(" + this.$target.index() + ")"), function (col) { $(col).after($("", {style: "width: " + width + "px;"})); }); } }, - on_remove: function () { + onRemove: function () { this._super.apply(this, arguments); // If we are removing a td or th element which was not alone on its row ... @@ -141,11 +135,7 @@ $.summernote.eventHandler.modules.popover.update = function ($popover, oStyle, i ajax.loadXML("/mass_mailing/static/src/xml/mass_mailing.xml", core.qweb); snippets_editor.Class.include({ - _get_snippet_url: function () { - var url = (typeof snippets_url !== "undefined" ? window["snippets_url"] : this._super.apply(this, arguments)); - return url; - }, - compute_snippet_templates: function (html) { + _computeSnippetTemplates: function (html) { var self = this; var ret = this._super.apply(this, arguments); @@ -362,9 +352,9 @@ snippets_editor.Class.include({ $old_layout.remove(); if (first_choice) { - self.add_default_snippet_text_classes($new_wrapper_content); + self._registerDefaultTexts($new_wrapper_content); } - self.show_blocks(); + self._disableUndroppableSnippets(); } }, }); @@ -381,12 +371,12 @@ odoo_top[callback+"_updown"] = function (value, fields_values, field_name) { var editor_enable = $('body').hasClass('editor_enable'); value = value || ""; - if(value !==_val) { + if (value !==_val) { if (editor_enable) { if (value !== fields_values[field_name]) { rte.history.recordUndo($editable); } - snippets_editor.instance.make_active(false); + core.bus.trigger('deactivate_snippet'); } if (value.indexOf('on_change_model_and_list') === -1) { @@ -400,7 +390,7 @@ odoo_top[callback+"_updown"] = function (value, fields_values, field_name) { } } - if (fields_values.mailing_model && web_editor.editor_bar) { + if (fields_values.mailing_model && editor_enable) { if (value.indexOf('on_change_model_and_list') !== -1) { odoo_top[callback+"_downup"](_val); } diff --git a/addons/payment/static/src/js/payment_transaction_portal.js b/addons/payment/static/src/js/payment_transaction_portal.js index f8dac1da3d9..26c0fb35d1e 100644 --- a/addons/payment/static/src/js/payment_transaction_portal.js +++ b/addons/payment/static/src/js/payment_transaction_portal.js @@ -1,11 +1,10 @@ odoo.define('payment.website_payment', function (require) { "use strict"; +require('web.dom_ready'); var ajax = require('web.ajax'); -$(document).ready(function () { - - if(!$('.o_website_payment').length) { + if (!$('.o_website_payment').length) { return $.Deferred().reject("DOM doesn't contain '.o_website_payment'"); } @@ -23,4 +22,3 @@ $(document).ready(function () { }); }); }); -}); diff --git a/addons/point_of_sale/static/src/js/pos.web_editor.js b/addons/point_of_sale/static/src/js/pos.web_editor.js index b960ff5532d..8a172085d14 100644 --- a/addons/point_of_sale/static/src/js/pos.web_editor.js +++ b/addons/point_of_sale/static/src/js/pos.web_editor.js @@ -1,7 +1,6 @@ odoo.define('point_of_sale.editor', function (require) { 'use strict'; - var Editor = require('web_editor.snippet.editor').Editor; var s_options = require('web_editor.snippets.options'); var core = require('web.core'); @@ -34,7 +33,7 @@ odoo.define('point_of_sale.editor', function (require) { this.$('.pos-adv').append($('
' + _t('Set your customized advertisement here') + '')); }, - clean_for_save: function() { + cleanForSave: function () { this.$('.pos_adv_onsave_remove').remove(); } }); @@ -48,4 +47,4 @@ odoo.define('point_of_sale.editor', function (require) { } }); -}); \ No newline at end of file +}); diff --git a/addons/point_of_sale/views/pos_templates.xml b/addons/point_of_sale/views/pos_templates.xml index 110b3fd03d2..57895f2637b 100644 --- a/addons/point_of_sale/views/pos_templates.xml +++ b/addons/point_of_sale/views/pos_templates.xml @@ -19,7 +19,7 @@ - + @@ -98,26 +98,26 @@
-
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • -
  • Set Custom Image...
  • +
  • Set Custom Image...
  • -
  • Hide
  • +
  • Hide
  • diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js index edc5ae4c2dd..5842596db1b 100644 --- a/addons/portal/static/src/js/portal.js +++ b/addons/portal/static/src/js/portal.js @@ -1,10 +1,10 @@ - -odoo.define('portal.portal', function(require) { +odoo.define('portal.portal', function (require) { 'use strict'; -$(document).ready(function () { + require('web.dom_ready'); - if(!$('.o_portal').length) { + + if (!$('.o_portal').length) { return $.Deferred().reject("DOM doesn't contain '.o_portal'"); } @@ -21,14 +21,14 @@ $(document).ready(function () { } if ($('.o_portal_search_panel').length) { - $('.o_portal_search_panel .search-submit').click(function() { + $('.o_portal_search_panel .search-submit').click(function () { var search = $.deparam(window.location.search.substring(1)); search.search_in = $(".o_portal_search_panel li.active a").attr("href").replace("#",""); search.search = $(".o_portal_search_panel input[name='search']").val(); window.location.search = $.param(search); }); - $('.o_portal_search_panel .dropdown-menu').find('a').click(function(e) { + $('.o_portal_search_panel .dropdown-menu').find('a').click(function (e) { e.preventDefault(); $(this).parents('.dropdown-menu').find('li').removeClass('active'); $(this).parent('li').addClass('active'); @@ -46,4 +46,3 @@ $(document).ready(function () { }); } }); -}); diff --git a/addons/portal/views/assets.xml b/addons/portal/views/assets.xml index a40533c7876..af6569e150f 100644 --- a/addons/portal/views/assets.xml +++ b/addons/portal/views/assets.xml @@ -1,15 +1,14 @@ - \ No newline at end of file + diff --git a/addons/survey/static/src/js/survey.js b/addons/survey/static/src/js/survey.js index d645411c694..b923235fd5c 100644 --- a/addons/survey/static/src/js/survey.js +++ b/addons/survey/static/src/js/survey.js @@ -1,6 +1,7 @@ odoo.define('survey.survey', function (require) { 'use strict'; +require('web.dom_ready'); var core = require('web.core'); var time = require('web.time'); var ajax = require('web.ajax'); diff --git a/addons/survey/static/src/js/survey_result.js b/addons/survey/static/src/js/survey_result.js index 8f8ca83efc9..373296ba8ef 100644 --- a/addons/survey/static/src/js/survey_result.js +++ b/addons/survey/static/src/js/survey_result.js @@ -1,7 +1,7 @@ odoo.define('survey.result', function (require) { 'use strict'; -var website = require('website.website'); +require('web.dom_ready'); if(!$('.js_surveyresult').length) { return $.Deferred().reject("DOM doesn't contain '.js_surveyresult'"); diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 8ad094b0ae8..6650022c486 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -1,3 +1,13 @@ +odoo.define('web.dom_ready', function (require) { +'use strict'; + +var def = $.Deferred(); +$(def.resolve.bind(def)); +return def; +}); + +//============================================================================== + odoo.define('web.dom', function (require) { "use strict"; @@ -245,5 +255,4 @@ return { return $container; }, }; - }); diff --git a/addons/web/static/src/js/report/report.editor.js b/addons/web/static/src/js/report/report.editor.js index a36b1596ca2..0059e39be02 100644 --- a/addons/web/static/src/js/report/report.editor.js +++ b/addons/web/static/src/js/report/report.editor.js @@ -1,7 +1,6 @@ odoo.define('report.editor', function (require) { 'use strict'; -var ajax = require('web.ajax'); var core = require('web.core'); var utils = require('report.utils'); var editor = require('web_editor.editor'); @@ -16,23 +15,18 @@ var trusted_origin = utils.build_origin(trusted_protocol, trusted_host); if (window.self !== window.top) { $(document.body).addClass('o_in_iframe'); // in order to apply css rules - // As `reload` is called after `save` and `cancel`, we nullify this function - // if the editor in order to be able to chain some deferred to `save` and - // `cancel`. - editor.reload = function () { - return $.when(); - }; - // And now we chain some deferred to `save` and `cancel` in order to inform // the report's client action that the actions are done. editor.Class.include({ save: function () { - return this._super.apply(this, arguments).then(function () { + // Force to not reload + return this._super(false).then(function () { window.parent.postMessage('report.editor:save_ok', trusted_origin); }); }, cancel: function () { - return this._super.apply(this, arguments).then(function () { + // Force to not reload + return this._super(false).then(function () { window.parent.postMessage('report.editor:discard_ok', trusted_origin); }); }, @@ -54,12 +48,12 @@ if (window.self !== window.top) { return; } - switch(message) { + switch (message) { case 'report.editor:ask_save': - editor.editor_bar.save(); + core.bus.trigger('editor_save_request'); break; case 'report.editor:ask_discard': - editor.editor_bar.cancel(); + core.bus.trigger('editor_discard_request'); break; default: } @@ -91,16 +85,19 @@ if (window.self !== window.top) { } options.registry.many2one.include({ - select_record: function (li) { - var self = this; - this._super(li); - if (this.$target.data('oe-field') === "partner_id") { - var $img = $('.header .row img:first'); - var css = window.getComputedStyle($img[0]); - $img.css("max-height", css.height+'px'); - $img.attr("src", "/web/image/res.partner/"+self.ID+"/image"); - setTimeout(function () { $img.removeClass('o_dirty'); },0); + _selectRecord: function ($li) { + this._super.apply(this, arguments); + if (this.$target.data('oe-field') !== 'partner_id') { + return; } + + var $img = $('.header .row img:first'); + var css = window.getComputedStyle($img[0]); + $img.css('max-height', css.height+'px'); + $img.attr('src', '/web/image/res.partner/' + this.ID + '/image'); + _.defer(function () { + $img.removeClass('o_dirty'); + }); } }); diff --git a/addons/web/static/src/less/report.editor.less b/addons/web/static/src/less/report.editor.less index 206ddb87037..544787ff6b2 100644 --- a/addons/web/static/src/less/report.editor.less +++ b/addons/web/static/src/less/report.editor.less @@ -14,7 +14,7 @@ body.o_in_iframe { margin-top: 40px!important; - div.o_css_editor { + div#web_editor-top-edit { > form { display: none; } diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js index 6b167c59edd..ab4828fe89d 100644 --- a/addons/web_editor/static/src/js/backend/fields.js +++ b/addons/web_editor/static/src/js/backend/fields.js @@ -7,7 +7,6 @@ var config = require('web.config'); var core = require('web.core'); var session = require('web.session'); var field_registry = require('web.field_registry'); - var transcoder = require('web_editor.transcoder'); var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin; @@ -107,8 +106,8 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi */ _getValue: function () { if (this.nodeOptions['style-inline']) { - transcoder.class_to_style(this.$content); - transcoder.font_to_img(this.$content); + transcoder.classToStyle(this.$content); + transcoder.fontToImg(this.$content); } return this.$content.html(); }, @@ -125,7 +124,7 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi // trigger a mouseup to refresh the editor toolbar this.$content.trigger('mouseup'); if (this.nodeOptions['style-inline']) { - transcoder.style_to_class(this.$content); + transcoder.styleToClass(this.$content); } // reset the history (otherwise clicking on undo before editing the // value will empty the editor) @@ -352,7 +351,7 @@ var FieldTextHtml = AbstractField.extend({ return; } if (this.mode === "edit") { - if(window.odoo[this.callback+"_updown"]) { + if (window.odoo[this.callback+"_updown"]) { // FIXME // window.odoo[this.callback+"_updown"](value, this.view.get_fields_values(), this.name); this.resize(); @@ -391,7 +390,7 @@ var FieldTextHtml = AbstractField.extend({ var layoutInfo = this.editor.rte.editable().data('layoutInfo'); $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); } - this.editor.buildingBlock.clean_for_save(); + this.editor.snippetsMenu.cleanForSave(); this._setValue(this.$content.html()); return this._super.apply(this, arguments); }, @@ -405,5 +404,4 @@ return { FieldTextHtmlSimple: FieldTextHtmlSimple, FieldTextHtml: FieldTextHtml, }; - }); diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/base.js index a241905c631..012068776e7 100644 --- a/addons/web_editor/static/src/js/base.js +++ b/addons/web_editor/static/src/js/base.js @@ -1,35 +1,64 @@ odoo.define('web_editor.base', function (require) { -"use strict"; +'use strict'; var ajax = require('web.ajax'); var session = require('web.session'); -var get_context = function (dict) { +var domReady = $.Deferred(); +$(domReady.resolve.bind(domReady)); + +return { + /** + * If a widget needs to be instantiated on page loading, it needs to wait + * for appropriate resources to be loaded. This function returns a Deferred + * which is resolved when the dom is ready, the session is bound + * (translations loaded) and the XML is loaded. This should however not be + * necessary anymore as widgets should not be parentless and should then be + * instantiated (directly or not) by the page main component (webclient, + * website root, editor bar, ...). The DOM will be ready then, the main + * component is in charge of waiting for the session and the XML can be + * lazy loaded thanks to the @see Widget.xmlDependencies key. + * + * @returns {Deferred} + */ + ready: function () { + return $.when(domReady, session.is_bound, ajax.loadXML()); + }, +}; +}); + +//============================================================================== + +odoo.define('web_editor.context', function (require) { +'use strict'; + +function getContext(context) { var html = document.documentElement; return _.extend({ - 'lang': (html.getAttribute('lang') || '').replace('-', '_'), - }, dict); + lang: (html.getAttribute('lang') || 'en_US').replace('-', '_'), + }, context || {}); +} +function getExtraContext(context) { + var html = document.documentElement; + return _.extend(getContext(), { + editable: !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python + translatable: !!html.dataset.translatable, + edit_translations: !!html.dataset.edit_translations, + }, context || {}); +} + +return { + get: getContext, + getExtra: getExtraContext, }; - -////////////////////////////////////////////////////////////////////////////////////////////////////////// - -var data = { - 'get_context': get_context, -}; - -var dom_ready = $.Deferred(); -$(document).ready(function () { - dom_ready.resolve(data); - // fix for ie - if ($.fn.placeholder) $('input, textarea').placeholder(); }); -data.dom_ready = dom_ready; -// todo: remove and load a bundle of translated templates -data.ready = function () { - return $.when(dom_ready, session.is_bound, ajax.loadXML()); -}; +//============================================================================== -return dom_ready; +odoo.define('web_editor.ready', function (require) { +'use strict'; +var base = require('web_editor.base'); + +return base.ready(); }); diff --git a/addons/web_editor/static/src/js/content/body_manager.js b/addons/web_editor/static/src/js/content/body_manager.js new file mode 100644 index 00000000000..1ba2d219770 --- /dev/null +++ b/addons/web_editor/static/src/js/content/body_manager.js @@ -0,0 +1,33 @@ +odoo.define('web_editor.BodyManager', function (require) { +'use strict'; + +var mixins = require('web.mixins'); +var session = require('web.session'); +var rootWidget = require('web_editor.root_widget'); + +/** + * Element which is designed to be unique and that will be the top-most element + * in the widget hierarchy. So, all other widgets will be indirectly linked to + * this Class instance. Its main role will be to retrieve RPC demands from its + * children and handle them. + */ +var BodyManager = rootWidget.RootWidget.extend(mixins.ServiceProvider, { + /** + * @constructor + */ + init: function () { + mixins.ServiceProvider.init.call(this); + this._super.apply(this, arguments); + }, + /** + * @override + */ + willStart: function () { + return $.when( + this._super.apply(this, arguments), + session.is_bound + ); + }, +}); +return BodyManager; +}); diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js index 240a0a36121..353104c2c93 100644 --- a/addons/web_editor/static/src/js/editor/editor.js +++ b/addons/web_editor/static/src/js/editor/editor.js @@ -1,86 +1,80 @@ odoo.define('web_editor.editor', function (require) { -"use strict"; +'use strict'; -var ajax = require('web.ajax'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); var core = require('web.core'); -var base = require('web_editor.base'); var rte = require('web_editor.rte'); +var snippetsEditor = require('web_editor.snippet.editor'); -var qweb = core.qweb; var _t = core._t; -var editor = {}; -////////////////////////////////////////////////////////////////////////////////////////////////////////// - -editor.dummy = function () {return true;}; // used for snippets, options... - -editor.editable = !!($('html').data('editable') || $("[data-oe-model]").length); // temporary hack, this should be done in python - -ajax.loadXML('/web_editor/static/src/xml/editor.xml', qweb); - -$(document).on('click', '.note-editable', function (ev) { - ev.preventDefault(); -}); - -$(document).on('submit', '.note-editable form .btn', function (ev) { - ev.preventDefault(); // Disable form submition in editable mode -}); - -$(document).on('hide.bs.dropdown', '.dropdown', function (ev) { - // Prevent dropdown closing when a contenteditable children is focused - if (ev.originalEvent - && $(ev.target).has(ev.originalEvent.target).length - && $(ev.originalEvent.target).is('[contenteditable]')) { - ev.preventDefault(); - } -}); - -////////////////////////////////////////////////////////////////////////////////////////////////////////// - -editor.reload = function () { - location.hash = "scrollTop=" + window.document.body.scrollTop; - if (location.search.indexOf("enable_editor") > -1) { - window.location.href = window.location.href.replace(/&?enable_editor(=[^&]*)?/g, ''); - } else { - window.location.reload(true); - } -}; - -////////////////////////////////////////////////////////////////////////////////////////////////////////// - -/* ----- TOP EDITOR BAR FOR ADMIN ---- */ - -base.ready().then(function () { - if (editor.editable && location.search.indexOf("enable_editor") >= 0) { - editor.editor_bar = new editor.Class(); - editor.editor_bar.prependTo(document.body); - } -}); - -editor.Class = Widget.extend({ +var EditorMenuBar = Widget.extend({ template: 'web_editor.editorbar', + xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], events: { - 'click button[data-action=save]': 'save', - 'click button[data-action=cancel]': 'cancel', + 'click button[data-action=save]': '_onSaveClick', + 'click button[data-action=cancel]': '_onCancelClick', }, + custom_events: { + request_history_undo_record: '_onHistoryUndoRecordRequest', + request_save: '_onSaveRequest', + }, + + /** + * Initializes RTE and snippets menu. + * + * @constructor + */ init: function (parent) { var self = this; var res = this._super.apply(this, arguments); - this.parent = parent; this.rte = new rte.Class(this); this.rte.on('rte:start', this, function () { self.trigger('rte:start'); }); + + // Snippets edition + var $editable = this.rte.editable(); + this.snippetsMenu = new snippetsEditor.Class(this, $editable); + return res; }, + /** + * @override + */ start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + + core.bus.on('editor_save_request', this, this._onSaveRequest); + core.bus.on('editor_discard_request', this, this._onDiscardRequest); + $('.dropdown-toggle').dropdown(); - this.display_placeholder(); + $(document).on('keyup', function (event) { + if ((event.keyCode === 8 || event.keyCode === 46)) { + var $target = $(event.target).closest('.o_editable'); + if (!$target.is(':has(*:not(p):not(br))') && !$target.text().match(/\S/)) { + $target.empty(); + } + } + }); + $(document).on('click', '.note-editable', function (ev) { + ev.preventDefault(); + }); + $(document).on('submit', '.note-editable form .btn', function (ev) { + ev.preventDefault(); // Disable form submition in editable mode + }); + $(document).on('hide.bs.dropdown', '.dropdown', function (ev) { + // Prevent dropdown closing when a contenteditable children is focused + if (ev.originalEvent + && $(ev.target).has(ev.originalEvent.target).length + && $(ev.originalEvent.target).is('[contenteditable]')) { + ev.preventDefault(); + } + }); - this.rte.on('change', this, this.proxy('rte_changed')); this.rte.start(); var flag = false; @@ -92,58 +86,144 @@ editor.Class = Widget.extend({ } }; - return this._super.apply(this, arguments); - }, - display_placeholder: function () { - var $area = $("#wrapwrap").find("[data-oe-model] .oe_structure.oe_empty, [data-oe-model].oe_structure.oe_empty, [data-oe-type=html]") - .filter(".oe_not_editable") - .filter(".oe_no_empty"); + // Snippets menu + defs.push(this.snippetsMenu.insertAfter(this.$el)); + this.rte.editable().find('*').off('mousedown mouseup click'); - this.on('rte:start', this, function () { - $area.attr("data-oe-placeholder", _t("Write Your Text Here")); - }); - this.on("snippets:ready", this, function () { - if ($("body").hasClass("editor_has_snippets")) { - $area.attr("data-oe-placeholder", _t("Write Your Text or Drag a Block Here")); - } - }); - - $(document).on("keyup", function (event) { - if((event.keyCode === 8 || event.keyCode === 46)) { - var $target = $(event.target).closest(".o_editable"); - if(!$target.is(":has(*:not(p):not(br))") && !$target.text().match(/\S/)) { - $target.empty(); - } - } - }); - }, - rte_changed: function () {}, - save: function () { - return this.rte.save().then(function () { - editor.reload(); + return $.when.apply($, defs).then(function () { + self.trigger_up('edit_mode'); }); }, /** - * Saves an RTE content, which always corresponds to a view section (?). + * @override */ - save_without_reload: function () { - return this.rte.save(); + destroy: function () { + this._super.apply(this, arguments); + core.bus.off('editor_save_request', this, this._onSaveRequest); + core.bus.off('editor_discard_request', this, this._onDiscardRequest); }, - cancel: function () { - return new $.Deferred(function (d) { - if (!rte.history.getEditableHasUndo().length) { - return d.resolve(); - } - var confirm = Dialog.confirm(null, _t("If you discard the current edition, all unsaved changes will be lost. You can cancel to return to the edition mode."), { - confirm_callback: d.resolve.bind(d) + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Asks the user if he really wants to discard its changes (if there are + * some of them), then simply reload the page if he wants to. + * + * @param {boolean} [reload=true] + * true if the page has to be reloaded when the user answers yes + * (do nothing otherwise but add this to allow class extension) + * @returns {Deferred} + */ + cancel: function (reload) { + var self = this; + var def = $.Deferred(); + if (!rte.history.getEditableHasUndo().length) { + def.resolve(); + } else { + var confirm = Dialog.confirm(this, _t("If you discard the current edition, all unsaved changes will be lost. You can cancel to return to the edition mode."), { + confirm_callback: def.resolve.bind(def), }); - confirm.on("closed", d, d.reject); - }).then(function () { - window.onbeforeunload = null; - editor.reload(); + confirm.on('closed', def, def.reject); + } + return def.then(function () { + if (reload !== false) { + window.onbeforeunload = null; + return self._reload(); + } }); }, + /** + * Asks the snippets to clean themself, then saves the page, then reloads it + * if asked to. + * + * @param {boolean} [reload=true] + * true if the page has to be reloaded after the save + * @returns {Deferred} + */ + save: function (reload) { + var self = this; + this.snippetsMenu.cleanForSave(); + return this.rte.save().then(function () { + if (reload !== false) { + return self._reload(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Reloads the page in non-editable mode, with the right scrolling. + * + * @private + * @returns {Deferred} (never resolved, the page is reloading anyway) + */ + _reload: function () { + window.location.hash = 'scrollTop=' + window.document.body.scrollTop; + if (window.location.search.indexOf('enable_editor') >= 0) { + window.location.href = window.location.href.replace(/&?enable_editor(=[^&]*)?/g, ''); + } else { + window.location.reload(true); + } + return $.Deferred(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "Discard" button is clicked -> discards the changes. + * + * @private + */ + _onCancelClick: function () { + this.cancel(); + }, + /** + * Called when an element askes to record an history undo -> records it. + * + * @private + * @param {OdooEvent} ev + */ + _onHistoryUndoRecordRequest: function (ev) { + this.rte.historyRecordUndo(ev.data.$target, ev.data.event); + }, + /** + * Called when the "Save" button is clicked -> saves the changes. + * + * @private + */ + _onSaveClick: function () { + this.save(); + }, + /** + * Called when a discard request is received -> discard the page content + * changes. + * + * @private + * @param {OdooEvent} ev + */ + _onDiscardRequest: function (ev) { + this.cancel(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure); + }, + /** + * Called when a save request is received -> saves the page content. + * + * @private + * @param {OdooEvent} ev + */ + _onSaveRequest: function (ev) { + ev.stopPropagation(); + this.save(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure); + }, }); -return editor; +return { + Class: EditorMenuBar, +}; }); diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index 92ab133263a..fffe8786f91 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -1,27 +1,24 @@ odoo.define('web_editor.rte', function (require) { 'use strict'; -var ajax = require('web.ajax'); -var core = require('web.core'); var concurrency = require('web.concurrency'); +var core = require('web.core'); var Widget = require('web.Widget'); +var weContext = require('web_editor.context'); var summernote = require('web_editor.summernote'); -var base = require('web_editor.base'); +var weWidgets = require('web_editor.widget'); var _t = core._t; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* Summernote Lib (neek change to make accessible: method and object) */ - +// Summernote Lib (neek change to make accessible: method and object) var dom = summernote.core.dom; var range = summernote.core.range; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* Change History to have a global History for all summernote instances */ - -var History = function History ($editable) { +// Change History to have a global History for all summernote instances +var History = function History($editable) { var aUndo = []; var pos = 0; + var toSnap; this.makeSnap = function (event, rng) { rng = rng || range.create(); @@ -42,27 +39,27 @@ var History = function History ($editable) { var $editable = $(oSnap.editable); if (document.documentMode) { - $editable.removeAttr("contentEditable").removeProp("contentEditable"); + $editable.removeAttr('contentEditable').removeProp('contentEditable'); } $editable.html(oSnap.contents).scrollTop(oSnap.scrollTop); - $(".oe_overlay").remove(); - $(".note-control-selection").hide(); + $('.oe_overlay').remove(); + $('.note-control-selection').hide(); - $editable.trigger("content_changed"); + $editable.trigger('content_changed'); try { - var r = oSnap.editable.innerHTML === "" ? range.create(oSnap.editable, 0) : range.createFromBookmark(oSnap.editable, oSnap.bookmark); + var r = oSnap.editable.innerHTML === '' ? range.create(oSnap.editable, 0) : range.createFromBookmark(oSnap.editable, oSnap.bookmark); r.select(); - } catch(e) { + } catch (e) { console.error(e); return; } - $(document).trigger("click"); - $(".o_editable *").filter(function () { + $(document).trigger('click'); + $('.o_editable *').filter(function () { var $el = $(this); - if($el.data('snippet-editor')) { + if ($el.data('snippet-editor')) { $el.removeData(); } }); @@ -74,13 +71,13 @@ var History = function History ($editable) { return; } - $editable.trigger("applySnap"); + $editable.trigger('applySnap'); - var evt = document.createEvent("MouseEvents"); - evt.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, target); + var evt = document.createEvent('MouseEvents'); + evt.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, target); target.dispatchEvent(evt); - $editable.trigger("keyup"); + $editable.trigger('keyup'); }); }; @@ -101,24 +98,25 @@ var History = function History ($editable) { pos--; } this.applySnap(aUndo[Math.max(--pos,0)]); - while (pos && (aUndo[pos].event === "blur" || (aUndo[pos+1].editable === aUndo[pos].editable && aUndo[pos+1].contents === aUndo[pos].contents))) { + while (pos && (aUndo[pos].event === 'blur' || (aUndo[pos+1].editable === aUndo[pos].editable && aUndo[pos+1].contents === aUndo[pos].contents))) { this.applySnap(aUndo[--pos]); } }; + this.hasUndo = function () { - return (toSnap && (toSnap.event !== "blur" && toSnap.event !== "activate" && toSnap.event !== "undo")) || + return (toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo')) || !!_.find(aUndo.slice(0, pos+1), function (undo) { - return undo.event !== "blur" && undo.event !== "activate" && undo.event !== "undo"; + return undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo'; }); }; this.getEditableHasUndo = function () { var editable = []; - if ((toSnap && (toSnap.event !== "blur" && toSnap.event !== "activate" && toSnap.event !== "undo"))) { + if ((toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo'))) { editable.push(toSnap.editable); } _.each(aUndo.slice(0, pos+1), function (undo) { - if (undo.event !== "blur" && undo.event !== "activate" && undo.event !== "undo") { + if (undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo') { editable.push(undo.editable); } }); @@ -128,31 +126,31 @@ var History = function History ($editable) { this.redo = function () { if (!aUndo[pos+1]) { return; } this.applySnap(aUndo[++pos]); - while (aUndo[pos+1] && aUndo[pos].event === "active") { + while (aUndo[pos+1] && aUndo[pos].event === 'active') { this.applySnap(aUndo[pos++]); } }; + this.hasRedo = function () { return aUndo.length > pos+1; }; - var toSnap; this.recordUndo = function ($editable, event, internal_history) { var self = this; if (!$editable) { var rng = range.create(); - if(!rng) return; - $editable = $(rng.sc).closest(".o_editable"); + if (!rng) return; + $editable = $(rng.sc).closest('.o_editable'); } - if (aUndo[pos] && (event === "applySnap" || event === "activate")) { + if (aUndo[pos] && (event === 'applySnap' || event === 'activate')) { return; } if (!internal_history) { - if (!event || !toSnap || !aUndo[pos-1] || toSnap.event === "activate") { // don't trigger change for all keypress + if (!event || !toSnap || !aUndo[pos-1] || toSnap.event === 'activate') { // don't trigger change for all keypress setTimeout(function () { - $editable.trigger("content_changed"); + $editable.trigger('content_changed'); },0); } } @@ -199,11 +197,9 @@ var History = function History ($editable) { }; var history = new History(); -////////////////////////////////////////////////////////////////////////////////////////////////////////// -// add focusIn to jQuery to allow to move caret into a div of a contentEditable area - -$.extend($.expr[':'],{ - o_editable: function (node,i,m) { +// jQuery extensions +$.extend($.expr[':'], { + o_editable: function (node, i, m) { while (node) { if (node.className && _.isString(node.className)) { if (node.className.indexOf('o_not_editable')!==-1 ) { @@ -216,9 +212,8 @@ $.extend($.expr[':'],{ node = node.parentNode; } return false; - } + }, }); - $.fn.extend({ focusIn: function () { if (this.length) { @@ -239,18 +234,16 @@ $.fn.extend({ range.create(dom.firstChild(this[0]), 0, next, next.textContent.length).select(); } return this; - } + }, }); -////////////////////////////////////////////////////////////////////////////////////////////////////////// - -/* ----- RICH TEXT EDITOR ---- */ - -var RTE = Widget.extend({ - init: function (EditorBar, config) { +// RTE +var RTEWidget = Widget.extend({ + /** + * @constructor + */ + init: function (parent, getConfig) { var self = this; - this.EditorBar = EditorBar; - data.rte = this; this._super.apply(this, arguments); this.init_bootstrap_carousel = $.fn.carousel; @@ -262,32 +255,12 @@ var RTE = Widget.extend({ return res; }; - if (config) { - this.config = config; - } + this._getConfig = getConfig || this._getDefaultConfig; + + weWidgets.computeFonts(); }, /** - * Add a record undo to history - * @param {DOM} target where the dom is changed is editable zone - */ - historyRecordUndo: function ($target, event, internal_history) { - var rng = range.create(); - var $editable = $(rng && rng.sc).closest(".o_editable"); - if (!rng || !$editable.length) { - $editable = $($target).closest(".o_editable"); - rng = range.create($target.closest("*")[0],0); - } else { - rng = $editable.data('range') || rng; - } - rng.select(); - history.recordUndo($editable, event, internal_history); - }, - /** - * Makes the page editable - * - * @param {Boolean} [restart=false] in case the edition was already set - * up once and is being re-enabled. - * @returns {$.Deferred} deferred indicating when the RTE is ready + * @override */ start: function () { var self = this; @@ -296,11 +269,14 @@ var RTE = Widget.extend({ $.fn.carousel = this.edit_bootstrap_carousel; - $(document).on("keydown.rte", this, this.onKeydown.bind(this)); - $(document).on("mousedown.rte activate.rte", this, this.onMousedown.bind(this)); - $(document).on("mouseup.rte", this, this.onMouseup.bind(this)); + core.bus.on('alt_dialog_demand', this, this._onAltDialogDemand); + core.bus.on('link_dialog_demand', this, this._onLinkDialogDemand); + core.bus.on('media_dialog_demand', this, this._onMediaDialogDemand); - $('.o_not_editable').attr("contentEditable", false); + $(document).on('mousedown.rte activate.rte', this, this._onMousedown.bind(this)); + $(document).on('mouseup.rte', this, this._onMouseup.bind(this)); + + $('.o_not_editable').attr('contentEditable', false); var $editable = this.editable(); @@ -310,28 +286,27 @@ var RTE = Widget.extend({ var $node = $(this); // add class to display inline-block for empty t-field - if (window.getComputedStyle(this).display === "inline" && $node.data('oe-type') !== "image") { + if (window.getComputedStyle(this).display === 'inline' && $node.data('oe-type') !== 'image') { $node.addClass('o_is_inline_editable'); } - $node.data('initInnerHTML', $node.html()); }); // start element observation $(document).on('content_changed', '.o_editable', function (event) { - self.trigger('change', this); + self.trigger_up('rte_change', {target: event.target}); $(this).addClass('o_dirty'); }); - $('#wrapwrap, .o_editable').on('click.rte', '*', this, this.onClick.bind(this)); + $('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this)); - $('body').addClass("editor_enable"); + $('body').addClass('editor_enable'); $(document) .tooltip({ selector: '[data-oe-readonly]', container: 'body', trigger: 'hover', - delay: { "show": 1000, "hide": 100 }, + delay: { 'show': 1000, 'hide': 100 }, placement: 'bottom', title: _t("Readonly field") }) @@ -341,113 +316,25 @@ var RTE = Widget.extend({ $(document).trigger('mousedown'); this.trigger('rte:start'); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this.cancel(); + this._super.apply(this, arguments); }, - save: function (context) { - var self = this; - - this.__saved = {}; // list of allready saved views and data - - var defs = $('.o_dirty') - .removeAttr('contentEditable') - .removeClass('o_dirty oe_carlos_danger o_is_inline_editable') - .map(function () { - var $el = $(this); - - $el.find('[class]').filter(function () { - if (!this.className.match(/\S/)) { - this.removeAttribute("class"); - } - }); - - // TODO: Add a queue with concurrency limit in webclient - // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js - return self.saving_mutex.exec(function () { - return self.saveElement($el, context) - .then(undefined, function (thing, response) { - // because ckeditor regenerates all the dom, - // we can't just setup the popover here as - // everything will be destroyed by the DOM - // regeneration. Add markings instead, and - // returns a new rejection with all relevant - // info - var id = _.uniqueId('carlos_danger_'); - $el.addClass('o_dirty oe_carlos_danger ' + id); - return $.Deferred().reject({ - id: id, - error: response.data, - }); - }); - }); - }).get(); - - return $.when.apply(null, defs).then(function () { - window.onbeforeunload = null; - }, function (failed) { - // If there were errors, re-enable edition - self.stop(); - self.start(); - // jquery's deferred being a pain in the ass - if (!_.isArray(failed)) { failed = [failed]; } - - _(failed).each(function (failure) { - var html = failure.error.exception_type === "except_osv"; - if (html) { - var msg = $("
    ").text(failure.error.message).html(); - var data = msg.substring(3,msg.length-2).split(/', u'/); - failure.error.message = '' + data[0] + '' + data[1]; - } - $('.o_editable.' + failure.id) - .removeClass(failure.id) - .popover({ - html: html, - trigger: 'hover', - content: failure.error.message, - placement: 'auto top', - }) - // Force-show popovers so users will notice them. - .popover('show'); - }); - }); - }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- /** - * Get HTML cloned element with text nodes escaped for XML storage + * Stops the RTE. */ - getEscapedElement: function ($el) { - var escaped_el = $el.clone(); - var to_escape = escaped_el.find('*').addBack(); - to_escape = to_escape.not(to_escape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack()); - to_escape.contents().each(function () { - if(this.nodeType === 3) { - this.nodeValue = $('
    ').text(this.nodeValue).html(); - } - }); - return escaped_el; - }, - - saveElement: function ($el, context) { - // remove multi edition - if ($el.data('oe-model')) { - var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression'); - if (this.__saved[key]) return $.when(); - this.__saved[key] = true; - } - var markup = this.getEscapedElement($el).prop('outerHTML'); - - return ajax.jsonRpc('/web/dataset/call', 'call', { - model: 'ir.ui.view', - method: 'save', - args: [ - $el.data('oe-id'), - markup, - $el.data('oe-xpath') || null, - _.omit(context || base.get_context(), 'lang') - ], - }); - }, - - stop: function () { + cancel: function () { if (this.$last) { this.$last.destroy(); this.$last = null; @@ -455,47 +342,299 @@ var RTE = Widget.extend({ $.fn.carousel = this.init_bootstrap_carousel; - $(document).off(".rte"); + core.bus.off('alt_dialog_demand', this, this._onAltDialogDemand); + core.bus.off('link_dialog_demand', this, this._onLinkDialogDemand); + core.bus.off('media_dialog_demand', this, this._onMediaDialogDemand); + + $(document).off('.rte'); $('#wrapwrap, .o_editable').off('.rte'); - $('.o_not_editable').removeAttr("contentEditable"); + $('.o_not_editable').removeAttr('contentEditable'); $(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte'); $(document).tooltip('destroy'); - $('body').removeClass("editor_enable"); + $('body').removeClass('editor_enable'); this.trigger('rte:stop'); }, - - cancel: function () { - $('.o_editable').each(function () { - var $node = $(this); - $node.data('initInnerHTML', $node.html()); - }); - this.stop(); + /** + * Returns the editable areas on the page. + * + * @returns {jQuery} + */ + editable: function () { + return $('#wrapwrap [data-oe-model]') + .not('.o_not_editable') + .filter(function () { + return !$(this).closest('.o_not_editable').length; + }) + .not('link, script') + .not('[data-oe-readonly]') + .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]') + .not('.oe_snippet_editor') + .add('.o_editable'); }, - - onClick: function (e) { - e.preventDefault(); + /** + * Records the current state of the given $target to be able to undo future + * changes. + * + * @see History.recordUndo + * @param {jQuery} $target + * @param {string} event + * @param {boolean} internal_history + */ + historyRecordUndo: function ($target, event, internal_history) { + var rng = range.create(); + var $editable = $(rng && rng.sc).closest('.o_editable'); + if (!rng || !$editable.length) { + $editable = $($target).closest('.o_editable'); + rng = range.create($target.closest('*')[0],0); + } else { + rng = $editable.data('range') || rng; + } + rng.select(); + history.recordUndo($editable, event, internal_history); }, + /** + * Searches all the dirty element on the page and saves them one by one. If + * one cannot be saved, this notifies it to the user and restarts rte + * edition. + * + * @param {Object} [context] - the context to use for saving rpc, default to + * the editor context found on the page + * @return {Deferred} rejected if the save cannot be done + */ + save: function (context) { + var self = this; - // handler for cancel editor - onKeydown: function (event) { - if (event.keyCode === 27 && !$('.modal-content:visible').length) { - _.defer(function () { - $('#editor-top-navbar [data-action="cancel"]').click(); - var $modal = $('.modal-content > .modal-body').parents(".modal:first"); - $modal.off('keyup.dismiss.bs.modal'); - _.delay(function () { - $modal.on('keyup.dismiss.bs.modal', function () { - $(this).modal('hide'); - }); - }, 500); + var $dirty = $('.o_dirty'); + $dirty + .removeAttr('contentEditable') + .removeClass('o_dirty oe_carlos_danger o_is_inline_editable'); + var defs = _.map($dirty, function (el) { + var $el = $(el); + + $el.find('[class]').filter(function () { + if (!this.className.match(/\S/)) { + this.removeAttribute('class'); + } }); + + // TODO: Add a queue with concurrency limit in webclient + // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js + return self.saving_mutex.exec(function () { + return self._saveElement($el, context || weContext.get()) + .then(function () { + $el.removeClass('o_dirty'); + }, function (response) { + // because ckeditor regenerates all the dom, we can't just + // setup the popover here as everything will be destroyed by + // the DOM regeneration. Add markings instead, and returns a + // new rejection with all relevant info + var id = _.uniqueId('carlos_danger_'); + $el.addClass('o_dirty oe_carlos_danger ' + id); + var html = (response.data.exception_type === 'except_osv'); + if (html) { + var msg = $('
    ', {text: response.data.message}).html(); + var data = msg.substring(3, msg.length -2).split(/', u'/); + response.data.message = '' + data[0] + '' + data[1]; + } + $('.o_editable.' + id) + .removeClass(id) + .popover({ + html: html, + trigger: 'hover', + content: response.data.message, + placement: 'auto top', + }) + .popover('show'); + }); + }); + }); + + return $.when.apply($, defs).then(function () { + window.onbeforeunload = null; + }, function (failed) { + // If there were errors, re-enable edition + self.cancel(); + self.start(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * When the users clicks on an editable element, this function allows to add + * external behaviors. + * + * @private + * @param {jQuery} $editable + */ + _enableEditableArea: function ($editable) { + if ($editable.data('oe-type') === "monetary") { + $editable.attr('contenteditable', false); + $editable.find('.oe_currency_value').attr('contenteditable', true); + } + if ($editable.is('[data-oe-model]') && !$editable.is('[data-oe-model="ir.ui.view"]') && !$editable.is('[data-oe-type="html"]')) { + $editable.data('layoutInfo').popover().find('.btn-group:not(.note-history)').remove(); } }, + /** + * When an element enters edition, summernote is initialized on it. This + * function returns the default configuration for the summernote instance. + * + * @see _getConfig + * @private + * @param {jQuery} $editable + * @returns {Object} + */ + _getDefaultConfig: function ($editable) { + return { + 'airMode' : true, + 'focus': false, + 'airPopover': [ + ['style', ['style']], + ['font', ['bold', 'italic', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['color', ['color']], + ['para', ['ul', 'ol', 'paragraph']], + ['table', ['table']], + ['insert', ['link', 'picture']], + ['history', ['undo', 'redo']], + ], + 'styleWithSpan': false, + 'inlinemedia' : ['p'], + 'lang': 'odoo', + 'onChange': function (html, $editable) { + $editable.trigger('content_changed'); + } + }; + }, + /** + * Gets jQuery cloned element with internal text nodes escaped for XML + * storage. + * + * @private + * @param {jQuery} $el + * @return {jQuery} + */ + _getEscapedElement: function ($el) { + var escaped_el = $el.clone(); + var to_escape = escaped_el.find('*').addBack(); + to_escape = to_escape.not(to_escape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack()); + to_escape.contents().each(function () { + if (this.nodeType === 3) { + this.nodeValue = $('
    ').text(this.nodeValue).html(); + } + }); + return escaped_el; + }, + /** + * Saves one (dirty) element of the page. + * + * @private + * @param {jQuery} $el - the element to save + * @param {Object} context - the context to use for the saving rpc + * @param {boolean} [withLang=false] + * false if the lang must be omitted in the context (saving "master" + * page element) + */ + _saveElement: function ($el, context, withLang) { + return this._rpc({ + model: 'ir.ui.view', + method: 'save', + args: [ + $el.data('oe-id'), + this._getEscapedElement($el).prop('outerHTML'), + $el.data('oe-xpath') || null, + withLang ? context : _.omit(context, 'lang') + ], + }); + }, - // activate editor - onMousedown: function (event) { - var $target = $(event.target); + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when any editable element is clicked -> Prevents default browser + * action for the element. + * + * @private + * @param {Event} e + */ + _onClick: function (e) { + e.preventDefault(); + }, + /** + * Called when a demand to open a alt dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onAltDialogDemand: function (data) { + var altDialog = new weWidgets.alt(this, + data.options || {}, + data.$editable, + data.media + ); + if (data.onSave) { + altDialog.on('save', this, data.onSave); + } + if (data.onCancel) { + altDialog.on('cancel', this, data.onCancel); + } + altDialog.open(); + }, + /** + * Called when a demand to open a link dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onLinkDialogDemand: function (data) { + var linkDialog = new weWidgets.LinkDialog(this, + data.options || {}, + data.$editable, + data.linkInfo + ); + if (data.onSave) { + linkDialog.on('save', this, data.onSave); + } + if (data.onCancel) { + linkDialog.on('cancel', this, data.onCancel); + } + linkDialog.open(); + }, + /** + * Called when a demand to open a media dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onMediaDialogDemand: function (data) { + var mediaDialog = new weWidgets.MediaDialog(this, + data.options || {}, + data.$editable, + data.media + ); + if (data.onSave) { + mediaDialog.on('save', this, data.onSave); + } + if (data.onCancel) { + mediaDialog.on('cancel', this, data.onCancel); + } + mediaDialog.open(); + }, + /** + * Called when the mouse is pressed on the document -> activate element + * edition. + * + * @private + * @param {Event} ev + */ + _onMousedown: function (ev) { + var $target = $(ev.target); var $editable = $target.closest('.o_editable'); if (!$editable.length) { @@ -534,8 +673,8 @@ var RTE = Widget.extend({ this.$last = null; } if ($editable.length && (!this.$last || this.$last[0] !== $editable[0]) && - ($target.closest('[contenteditable]').attr('contenteditable') || "").toLowerCase() !== 'false') { - $editable.summernote(this.config($editable)); + ($target.closest('[contenteditable]').attr('contenteditable') || '').toLowerCase() !== 'false') { + $editable.summernote(this._getConfig($editable)); $editable.data('NoteHistory', history); this.$last = $editable; @@ -545,7 +684,7 @@ var RTE = Widget.extend({ document.execCommand('enableObjectResizing', false, false); document.execCommand('enableInlineTableEditing', false, false); document.execCommand('2D-position', false, false); - } catch (e) {} + } catch (e) { /* */ } document.body.addEventListener('resizestart', function (evt) {evt.preventDefault(); return false;}); document.body.addEventListener('movestart', function (evt) {evt.preventDefault(); return false;}); document.body.addEventListener('dragstart', function (evt) {evt.preventDefault(); return false;}); @@ -558,15 +697,17 @@ var RTE = Widget.extend({ $target.trigger('mousedown'); // for activate selection on picture } - this.onEnableEditableArea($editable); + this._enableEditableArea($editable); } }, - - onEnableEditableArea: function ($editable) { - }, - - onMouseup: function (event) { - var $target = $(event.target); + /** + * Called when the mouse is unpressed on the document. + * + * @private + * @param {Event} ev + */ + _onMouseup: function (ev) { + var $target = $(ev.target); var $editable = $target.closest('.o_editable'); if (!$editable.length) { @@ -582,56 +723,17 @@ var RTE = Widget.extend({ // whitespace characters (so Tripleclicking then typing text will remove // the whole paragraph instead of its content). // http://stackoverflow.com/questions/38467334/why-does-google-chrome-always-add-space-after-selected-text - if ($.browser.chrome === true && event.originalEvent.detail === 3) { + if ($.browser.chrome === true && ev.originalEvent.detail === 3) { var currentSelection = range.create(); if (currentSelection.sc.parentNode === currentSelection.ec) { range.create(currentSelection.sc, currentSelection.so, currentSelection.sc, currentSelection.sc.length).select(); } } }, - - editable: function () { - return $('#wrapwrap [data-oe-model]') - .not('.o_not_editable') - .filter(function () { - return !$(this).closest('.o_not_editable').length; - }) - .not('link, script') - .not('[data-oe-readonly]') - .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]') - .not('.oe_snippet_editor') - .add('.o_editable'); - }, - - config: function ($editable) { - return { - 'airMode' : true, - 'focus': false, - 'airPopover': [ - ['style', ['style']], - ['font', ['bold', 'italic', 'underline', 'clear']], - ['fontsize', ['fontsize']], - ['color', ['color']], - ['para', ['ul', 'ol', 'paragraph']], - ['table', ['table']], - ['insert', ['link', 'picture']], - ['history', ['undo', 'redo']], - ], - 'styleWithSpan': false, - 'inlinemedia' : ['p'], - 'lang': "odoo", - 'onChange': function (html, $editable) { - $editable.trigger("content_changed"); - } - }; - }, }); - -var data = { +return { + Class: RTEWidget, history: history, - Class: RTE }; -return data; - }); diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js index 979211cd4d7..7067ab28af6 100644 --- a/addons/web_editor/static/src/js/editor/rte.summernote.js +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -3,39 +3,32 @@ odoo.define('web_editor.rte.summernote', function (require) { var core = require('web.core'); var ajax = require('web.ajax'); -var base = require('web_editor.base'); +var weContext = require('web_editor.context'); var widgets = require('web_editor.widget'); var rte = require('web_editor.rte'); var QWeb = core.qweb; var _t = core._t; -////////////////////////////////////////////////////////////////////////////////////////////////////////// - ajax.jsonRpc('/web/dataset/call', 'call', { 'model': 'ir.ui.view', 'method': 'read_template', - 'args': ['web_editor.colorpicker', base.get_context()] + 'args': ['web_editor.colorpicker', weContext.get()] }).done(function (data) { QWeb.add_template(data); }); -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* Summernote Lib (neek change to make accessible: method and object) */ - +// Summernote Lib (neek change to make accessible: method and object) var dom = $.summernote.core.dom; var range = $.summernote.core.range; var eventHandler = $.summernote.eventHandler; var renderer = $.summernote.renderer; -// var options = $.summernote.options; var tplButton = renderer.getTemplate().button; var tplIconButton = renderer.getTemplate().iconButton; var tplDropdown = renderer.getTemplate().dropdown; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* update and change the popovers content, and add history button */ - +// Update and change the popovers content, and add history button var fn_createPalette = renderer.createPalette; renderer.createPalette = function ($container, options) { fn_createPalette.call(this, $container, options); @@ -322,10 +315,8 @@ eventHandler.modules.handle.update = function ($handle, oStyle, isAirMode) { } }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* hack for image and link editor */ - -function getImgTarget ($editable) { +// Hack for image and link editor +function getImgTarget($editable) { var $handle = $editable ? dom.makeLayoutInfo($editable).handle() : undefined; return $(".note-control-selection", $handle).data('target'); } @@ -375,18 +366,18 @@ eventHandler.modules.linkDialog.showLinkDialog = function ($editable, $dialog, l $editable.data('range').select(); $editable.data('NoteHistory').recordUndo(); - var editor = new widgets.LinkDialog(null, {}, $editable, linkInfo).open(); - var def = new $.Deferred(); - editor.on("save", this, function (linkInfo) { - linkInfo.range.select(); - $editable.data('range', linkInfo.range); - def.resolve(linkInfo); - $editable.trigger('keyup'); - $('.note-popover .note-link-popover').show(); - }); - editor.on("cancel", this, function () { - def.reject(); + core.bus.trigger('link_dialog_demand', { + $editable: $editable, + linkInfo: linkInfo, + onSave: function (linkInfo) { + linkInfo.range.select(); + $editable.data('range', linkInfo.range); + def.resolve(linkInfo); + $editable.trigger('keyup'); + $('.note-popover .note-link-popover').show(); + }, + onCancel: def.reject.bind(def), }); return def; }; @@ -395,18 +386,22 @@ eventHandler.modules.imageDialog.showImageDialog = function ($editable) { if (r.sc.tagName && r.sc.childNodes.length) { r.sc = r.sc.childNodes[r.so]; } - new widgets.MediaDialog(null, {}, $editable, dom.isImg(r.sc) ? r.sc : null).open(); + core.bus.trigger('media_dialog_demand', { + $editable: $editable, + media: dom.isImg(r.sc) ? r.sc : null, + }); return new $.Deferred().reject(); }; $.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) { var $editable = layoutInfo.editable(); var $selection = layoutInfo.handle().find('.note-control-selection'); - var media = $selection.data('target'); - new widgets.alt(null, {}, $editable, media).open(); + core.bus.trigger('alt_dialog_demand', { + $editable: $editable, + media: $selection.data('target'), + }); }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// - +// Utils var fn_is_void = dom.isVoid || function () {}; dom.isVoid = function (node) { return fn_is_void(node) || dom.isImgFont(node) || (node && node.className && node.className.match(/(^|\s)media_iframe_video(\s|$)/i)); @@ -440,13 +435,11 @@ dom.isFont = function (node) { return fn_is_font(node) || dom.isImgFont(node); }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// - var fn_visible = $.summernote.pluginEvents.visible; $.summernote.pluginEvents.visible = function (event, editor, layoutInfo) { var res = fn_visible.apply(this, arguments); var rng = range.create(); - if(!rng) return res; + if (!rng) return res; var $node = $(dom.node(rng.sc)); if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) && $node.hasClass("o_editable") && @@ -582,11 +575,12 @@ $.summernote.pluginEvents.codeview = function (event, editor, layoutInfo, enable } }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* fix ie and re-range to don't break snippet*/ - +// Fix ie and re-range to don't break snippet +var last_div; +var last_div_change; +var last_editable; var initial_data = {}; -function reRangeSelectKey (event) { +function reRangeSelectKey(event) { initial_data.range = null; if (event.shiftKey && event.keyCode >= 37 && event.keyCode <= 40 && !$(event.target).is("input, textarea, select")) { var r = range.create(); @@ -598,7 +592,7 @@ function reRangeSelectKey (event) { } } } -function reRangeSelect (event, dx, dy) { +function reRangeSelect(event, dx, dy) { var r = range.create(); if (!r || r.isCollapsed()) return; @@ -615,7 +609,7 @@ function reRangeSelect (event, dx, dy) { $(data.sc).closest('.o_editable').data('range', r); return r; } -function summernote_mouseup (event) { +function summernote_mouseup(event) { if ($(event.target).closest("#web_editor-top-navbar, .note-popover").length) { return; } @@ -638,7 +632,7 @@ function summernote_mouseup (event) { } } var remember_selection; -function summernote_mousedown (event) { +function summernote_mousedown(event) { rte.history.splitNext(); var $editable = $(event.target).closest(".o_editable, .note-editor"); @@ -663,19 +657,19 @@ function summernote_mousedown (event) { var r_editable = editables.has((r||{}).sc); if (!r_editable.closest('.note-editor').is($editable) && !r_editable.filter('.o_editable').is(editables)) { var saved_editable = editables.has((remember_selection||{}).sc); - if($editable.length && !saved_editable.closest('.o_editable, .note-editor').is($editable)) { + if ($editable.length && !saved_editable.closest('.o_editable, .note-editor').is($editable)) { remember_selection = range.create(dom.firstChild($editable[0]), 0); - } else if(!saved_editable.length) { + } else if (!saved_editable.length) { remember_selection = undefined; } - if(remember_selection) { + if (remember_selection) { try { remember_selection.select(); } catch (e) { console.warn(e); } } - } else if(r_editable.length) { + } else if (r_editable.length) { remember_selection = r; } @@ -691,18 +685,15 @@ function summernote_mousedown (event) { } } -var last_div; -var last_div_change; -var last_editable; -function summernote_ie_fix (event, pred) { +function summernote_ie_fix(event, pred) { var editable; var div; var node = event.target; - while(node.parentNode) { + while (node.parentNode) { if (!div && pred(node)) { div = node; } - if(last_div !== node && (node.getAttribute('contentEditable')==='false' || node.className && (node.className.indexOf('o_not_editable') !== -1))) { + if (last_div !== node && (node.getAttribute('contentEditable')==='false' || node.className && (node.className.indexOf('o_not_editable') !== -1))) { break; } if (node.className && node.className.indexOf('o_editable') !== -1) { @@ -772,13 +763,13 @@ eventHandler.attach = function (oLayoutInfo, options) { eventHandler.modules.linkDialog.show(oLayoutInfo); }); - if(oLayoutInfo.editor().is('[data-oe-model][data-oe-type="image"]')) { + if (oLayoutInfo.editor().is('[data-oe-model][data-oe-type="image"]')) { oLayoutInfo.editor().on('click', 'img', function (event) { $(event.target).trigger("dblclick"); }); } oLayoutInfo.editable().on('mousedown', function (e) { - if(dom.isImg(e.target)) { + if (dom.isImg(e.target)) { range.createFromNode(e.target).select(); } }); @@ -875,9 +866,7 @@ eventHandler.detach = function (oLayoutInfo, options) { $(document).off("keyup", reRangeSelectKey); }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* Translation for odoo */ - +// Translation for odoo $.summernote.lang.odoo = { font: { bold: _t('Bold'), diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index a0bfb8b300c..c0299fc2969 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -1,351 +1,1112 @@ odoo.define('web_editor.snippet.editor', function (require) { 'use strict'; -var Class = require('web.Class'); -var ajax = require('web.ajax'); var core = require('web.core'); var dom = require('web.dom'); var Widget = require('web.Widget'); -var editor = require('web_editor.editor'); -var animation = require('web_editor.snippets.animation'); var options = require('web_editor.snippets.options'); -var qweb = core.qweb; var _t = core._t; -ajax.loadXML('/web_editor/static/src/xml/snippets.xml', qweb); - -var data = {}; - -/* ----- SNIPPET SELECTOR ---- */ - -data.globalSelector = { +var globalSelector = { closest: function () { return $(); }, all: function () { return $(); }, is: function () { return false; }, }; /** - * Snippet editor main class. - * Management of drag&drop and focus/blur of snippets. - * Previously named "BuildingBlock". + * Management of the overlay and option list for a snippet. */ -data.Class = Widget.extend({ - id: "oe_snippets", - activeSnippets: [], - init: function (parent, $editable) { - this.$editable = $editable; - - this._super.apply(this, arguments); - if(!$('#oe_manipulators').length) { - $("
    ", {id: "oe_manipulators", 'class': "o_css_editor"}).appendTo('body'); - } - this.$active_snipped_id = false; - this.snippets = []; - - data.instance = this; +var SnippetEditor = Widget.extend({ + template: 'web_editor.snippet_overlay', + xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], + events: { + 'click .oe_snippet_parent': '_onParentButtonClick', + 'click .oe_snippet_clone': '_onCloneClick', + 'click .oe_snippet_remove': '_onRemoveClick', }, + custom_events: { + cover_update: '_onCoverUpdate', + option_update: '_onOptionUpdate', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {DOMElement} target + */ + init: function (parent, target, templateOptions) { + this._super.apply(this, arguments); + this.$target = $(target); + this.$target.data('snippet-editor', this); + this.templateOptions = templateOptions; + }, + /** + * @override + */ start: function () { var self = this; + var defs = [this._super.apply(this, arguments)]; - $(window).resize(function () { - _.defer(function () { $(document).click(); }); - }); + // Hide parent button if no parent + var $parent = globalSelector.closest(this.$target.parent()); + if (!$parent.length) { + this.$('.oe_snippet_parent').remove(); + } - this.fetch_snippet_templates(); - this.bind_snippet_click_editor(); + // Initialize the associated options (see snippets.options.js) + defs.push(this._initializeOptions()); - $(document).on('click', '.dropdown-submenu a[tabindex]', function (e) { - e.preventDefault(); - }); - - var _isNotBreakable = $.summernote.core.dom.isNotBreakable; - $.summernote.core.dom.isNotBreakable = function (node) { - return _isNotBreakable(node) || $(node).is('div') || data.globalSelector.is($(node)); - }; - - $(window).on('resize', function () { - if (self.$active_snipped_id && self.$active_snipped_id.data("snippet-editor")) { - self.cover_target(self.$active_snipped_id.data("snippet-editor").$overlay, self.$active_snipped_id); - } - }); - - $(document).on('mousemove', function () { - if (self.$active_snipped_id && self.$active_snipped_id.data("snippet-editor")) { - self.$active_snipped_id.data("snippet-editor").$overlay.removeClass('o_keypress'); - } - }); - $(document).on('keydown', function (event) { - if (self.$active_snipped_id && self.$active_snipped_id.data("snippet-editor")) { - self.$active_snipped_id.data("snippet-editor").$overlay.addClass('o_keypress'); - } - if ((event.metaKey || (event.ctrlKey && !event.altKey)) && event.shiftKey && event.keyCode >= 48 && event.keyCode <= 57) { - self.$('.scroll:first > ul li:eq('+(event.keyCode-49)+') a').trigger("click"); - event.preventDefault(); - } - }); - }, - - _get_snippet_url: function () { - return '/web_editor/snippets'; - }, - _add_check_selector : function (selector, no_check, is_children) { - var self = this; - selector = selector.split(/\s*,/).join(":not(.o_snippet_not_selectable), ") + ":not(.o_snippet_not_selectable)"; - - if (no_check) { - return { - closest: function ($from, parentNode) { - return $from.closest(selector, parentNode); - }, - all: function ($from) { - return $from ? dom.cssFind($from, selector) : $(selector); - }, - is: function ($from) { - return $from.is(selector); - } - }; + // Initialize move/clone/remove buttons + if (!this.$target.parent().is(':o_editable')) { + this.$el.find('.oe_snippet_move, .oe_snippet_clone, .oe_snippet_remove').remove(); } else { - selector = selector.split(/\s*,/).join(":o_editable, ") + ":o_editable"; - return { - closest: function ($from, parentNode) { - var parents = self.$editable.get(); - return $from.closest(selector, parentNode).filter(function () { - var node = this; - while (node.parentNode) { - if (parents.indexOf(node)!==-1) { - return true; - } - node = node.parentNode; - } - return false; - }); + this.dropped = false; + this.$el.draggable({ + greedy: true, + appendTo: 'body', + cursor: 'move', + handle: '.oe_snippet_move', + cursorAt: { + left: 18, + top: 14 }, - all: is_children ? function ($from) { - return dom.cssFind($from || self.$editable, selector); - } : function ($from) { - $from = $from || self.$editable; - return $from.filter(selector).add(dom.cssFind($from, selector)); + helper: function () { + var $clone = $(this).clone().css({width: '24px', height: '24px', border: 0}); + $clone.find('.oe_overlay_options >:not(:contains(.oe_snippet_move)), .oe_handle').remove(); + $clone.find(':not(.glyphicon)').css({position: 'absolute', top: 0, left: 0}); + $clone.appendTo('body').removeClass('hidden'); + return $clone; }, - is: function ($from) { - return $from.is(selector); - } - }; + start: _.bind(self._onDragAndDropStart, self), + stop: _.bind(self._onDragAndDropStop, self) + }); + } + + return $.when.apply($, defs); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$target.removeData('snippet-editor'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Notifies all the associated snippet options that the snippet has just + * been dropped in the page. + */ + buildSnippet: function () { + for (var i in this.styles) { + this.styles[i].onBuilt(); } }, - - fetch_snippet_templates: function () { - var self = this; - var url = this._get_snippet_url(); - if (!url || !url.length) { - this.$el.detach(); + /** + * Notifies all the associated snippet options that the template which + * contains the snippet is about to be saved. + */ + cleanForSave: function () { + if (this.isDestroyed()) { return; } - return ajax.jsonRpc(url, 'call', {}).then(function (html) { - self.compute_snippet_templates(html); - self.trigger("snippets:ready"); - }, function () { - console.warn('Snippets template not found:', url); + _.each(this.styles, function (option) { + option.cleanForSave(); }); }, - compute_snippet_templates: function (html) { - var self = this; - var $html = $(html); - var $scroll = $html.siblings("#o_scroll"); + /** + * Makes the editor overlay cover the associated snippet. + */ + cover: function () { + var mt = parseInt(this.$target.css('margin-top') || 0); + var offset = this.$target.offset(); + var manipulatorOffset = this.$el.parent().offset(); + offset.top -= (manipulatorOffset.top + mt); + offset.left -= manipulatorOffset.left; + this.$el.css({ + width: this.$target.outerWidth(), + left: offset.left, + top: offset.top, + }); + this.$('.oe_handles').css('height', this.$target.outerHeight(true)); + this.$el.toggleClass('o_top_cover', offset.top < 15); + }, + /** + * Displays/Hides the editor overlay and notifies the associated snippet + * options. Note: when it is displayed, this is here that the parent + * snippet options are moved to the editor overlay. + * + * @param {boolean} focus - true to display, false to hide + */ + toggleFocus: function (focus) { + var do_action = (focus ? _do_action_focus : _do_action_blur); - if (!$scroll.length) { - throw new Error("Wrong snippets xml definition"); + // Attach own and parent options on the current overlay + var $style_button = this.$el.find('.oe_options'); + var $ul = $style_button.find('ul:first'); + var $headers = $ul.find('.dropdown-header:data(editor)'); + _.each($headers, (function (el) { + var $el = $(el); + var styles = _.values($el.data('editor').styles); + if ($el.data('editor') !== this) { + styles = _.filter(styles, function (option) { return !option.preventChildPropagation; }); + } + + var count = 0; + _.each(_.sortBy(styles, '__order').reverse(), function (style) { + if (do_action(style, $el)) { + count++; + } + }); + $el.toggleClass('hidden', count === 0); + }).bind(this)); + + // Activate the overlay + $style_button.toggleClass('hidden', $ul.children(':not(.o_main_header):not(.divider):not(.hidden)').length === 0); + this.cover(); + this.$el.toggleClass('oe_active', !!focus); + + function _do_action_focus(style, $dest) { + style.$el.insertAfter($dest); + style.onFocus(); + return (style.$el.length > 0); + } + function _do_action_blur(style, $dest) { + style.$el.detach(); + style.onBlur(); + return false; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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. + * + * @private + */ + _getName: function () { + if (this.$target.data('name') !== undefined) { + return this.$target.data('name'); + } + if (this.$target.parent('.row').length) { + return _t("Column"); + } + return _t("Block"); + }, + /** + * Instantiates the snippet's options. + * + * @private + */ + _initializeOptions: function () { + var self = this; + var $styles = this.$el.find('.oe_options'); + var $ul = $styles.find('ul:first'); + this.styles = {}; + this.selectorSiblings = []; + this.selectorChildren = []; + + var i = 0; + $ul.append($('
  • ', {class: 'dropdown-header o_main_header', text: this._getName()}).data('editor', this)); + var defs = _.map(this.templateOptions, function (val, option_id) { + if (!val.selector.is(self.$target)) { + return; + } + if (val['drop-near']) self.selectorSiblings.push(val['drop-near']); + if (val['drop-in']) self.selectorChildren.push(val['drop-in']); + + var optionName = val.option; + var $el = val.$el.children('li').clone(true).addClass('snippet-option-' + optionName); + var option = new (options.registry[optionName] || options.Class)(self, self.$target, self.$el); + self.styles[optionName || _.uniqueId('option')] = option; + option.__order = i++; + return option.attachTo($el); + }); + $ul.append($('
  • ', {class: 'divider'})); + + var $parents = this.$target.parents(); + _.each($parents, function (parent) { + var parentEditor = $(parent).data('snippet-editor'); + if (parentEditor) { + for (var styleName in parentEditor.styles) { + if (!parentEditor.styles[styleName].preventChildPropagation) { + $ul.append($('
  • ', {class: 'dropdown-header o_parent_editor_header', text: parentEditor._getName()}).data('editor', parentEditor)); + break; + } + } + } + }); + + if (!this.selectorSiblings.length && !this.selectorChildren.length) { + this.$el.find('.oe_snippet_move, .oe_snippet_clone').addClass('hidden'); } - // t-snippet - $html.find('[data-oe-type="snippet"]').each(function () { - $(this).children().attr('data-oe-type', "snippet").attr('data-oe-thumbnail', $(this).data('oe-thumbnail')); - }); - // end + this.$el.find('[data-toggle="dropdown"]').dropdown(); - self.templateOptions = []; - var selector = []; - var $styles = $html.find("[data-js], [data-selector]"); + return $.when.apply($, defs); + }, + /** + * Removes the associated snippet from the DOM and destroys the associated + * editor (itself). + * + * @private + */ + _removeSnippet: function () { + this.toggleFocus(false); + + 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(); + } + }, + }); + + var $parent = this.$target.parent(); + this.$target.find('*').andSelf().tooltip('destroy'); + this.$target.remove(); + this.$el.remove(); + + var node = $parent[0]; + if (node && node.firstChild) { + $.summernote.core.dom.removeSpace(node, node.firstChild, 0, node.lastChild, 1); + if (!node.firstChild.tagName && node.firstChild.textContent === ' ') { + node.removeChild(node.firstChild); + } + } + + if ($parent.closest(':data("snippet-editor")').length) { + while (!$parent.data('snippet-editor')) { + var $nextParent = $parent.parent(); + if ($parent.children().length === 0 && $parent.text().trim() === '' && !$parent.hasClass('oe_structure')) { + $parent.remove(); + } + $parent = $nextParent; + } + if ($parent.children().length === 0 && $parent.text().trim() === '' && !$parent.hasClass('oe_structure')) { + _.defer(function () { + $parent.data('snippet-editor')._removeSnippet(); + }); + } + } + + // clean editor if they are image or table in deleted content + $('.note-control-selection').hide(); + $('.o_table_handler').remove(); + + this.trigger_up('snippet_removed'); + this.destroy(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the 'clone' button is clicked. + * + * @private + * @param {Event} ev + */ + _onCloneClick: function (ev) { + ev.preventDefault(); + var $clone = this.$target.clone(false); + + this.trigger_up('request_history_undo_record', {$target: this.$target}); + + this.$target.after($clone); + 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)), + }); + } + }, + }); + }, + /** + * Called when the overlay dimensions/positions should be recomputed. + * + * @private + */ + _onCoverUpdate: function () { + this.cover(); + }, + /** + * Called when the snippet is starting to be dragged thanks to the 'move' + * button. + * + * @private + */ + _onDragAndDropStart: function () { + var self = this; + self.size = { + width: self.$target.width(), + height: self.$target.height() + }; + self.$target.after('
  • ", {"class": "dropdown-header o_main_header", text: this.getName()}).data("editor", this)); - _.each(this.buildingBlock.templateOptions, function (val, option_id) { - if (!val.selector.is(self.$target)) { - return; - } - if (val['drop-near']) self.selector_siblings.push(val['drop-near']); - if (val['drop-in']) self.selector_children.push(val['drop-in']); - - var option = val['option']; - self.styles[option] = new (options.registry[option] || options.Class)(self.buildingBlock, self, self.$target, option_id); - $ul.append(self.styles[option].$el.addClass("snippet-option-" + option)); - self.styles[option].start(); - self.styles[option].__order = i++; - }); - $ul.append($("
  • ", {"class": "divider"})); - - var $parents = this.$target.parents(); - _.each($parents, function (parent) { - var parentEditor = $(parent).data("snippet-editor"); - if (parentEditor) { - for (var styleName in parentEditor.styles) { - if (!parentEditor.styles[styleName].preventChildPropagation) { - $ul.append($("
  • ", {"class": "dropdown-header o_parent_editor_header", text: parentEditor.getName()}).data("editor", parentEditor)); - break; - } - } - } - }); - - if (!this.selector_siblings.length && !this.selector_children.length) { - this.$overlay.find(".oe_snippet_move, .oe_snippet_clone").addClass('hidden'); - } - - this.$overlay.find('[data-toggle="dropdown"]').dropdown(); - }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- /** - * The init_parent_options method initializes the "go to parent" button and create the editor options - * management for them if they do not already have one. + * Called when a child editor asks for insertion zones to be enabled. + * + * @private + * @param {OdooEvent} ev */ - init_parent_options: function () { - var self = this; - var $button = this.$overlay.find('.oe_snippet_parent'); - var $parent = data.globalSelector.closest(this.$target.parent()); - if (!$parent.data("snippet-editor")) { - this.buildingBlock.create_snippet_editor($parent); - } - - $button.toggleClass("hidden", $parent.length === 0); - $button.off("click").on('click', function (event) { - event.preventDefault(); - _.defer(function () { - self.buildingBlock.make_active($parent); - }); - }); + _onActivateInsertionZones: function (ev) { + this._activateInsertionZones(ev.data.$selectorSiblings, ev.data.$selectorChildren); }, - - on_clone: function (event) { - event.preventDefault(); - var $clone = this.$target.clone(false); - - this.buildingBlock.getParent().rte.historyRecordUndo(this.$target); - - this.$target.after($clone); - this.buildingBlock.call_for_all_snippets($clone, function (editor, $snippet) { - for (var i in editor.styles) { - editor.styles[i].on_clone($snippet, { - isCurrent: ($snippet.is($clone)), - }); - } - }); - return false; + /** + * Called when a child editor asks to operate some operation on all child + * snippet of a DOM element. + * + * @private + * @param {OdooEvent} ev + */ + _onCallForEachChildSnippet: function (ev) { + this._callForEachChildSnippet(ev.data.$snippet, ev.data.callback); }, - - on_remove: function (event) { - if (event !== undefined) { - event.preventDefault(); - this.buildingBlock.getParent().rte.historyRecordUndo(this.$target); - } - - this.on_blur(); - - var index = _.indexOf(this.buildingBlock.snippets, this.$target.get(0)); - this.buildingBlock.call_for_all_snippets(this.$target, function (editor, $snippet) { - for (var i in editor.styles) { - editor.styles[i].on_remove(); - } - }); - delete this.buildingBlock.snippets[index]; - - var $parent = this.$target.parent(); - this.$target.find("*").andSelf().tooltip("destroy"); - this.$target.remove(); - this.$overlay.remove(); - - var node = $parent[0]; - if (node && node.firstChild) { - $.summernote.core.dom.removeSpace(node, node.firstChild, 0, node.lastChild, 1); - if (!node.firstChild.tagName && node.firstChild.textContent === " ") { - node.removeChild(node.firstChild); - } - } - - if($parent.closest(":data(\"snippet-editor\")").length) { - while (!$parent.data("snippet-editor")) { - var $nextParent = $parent.parent(); - if ($parent.children().length === 0 && $parent.text().trim() === "" && !$parent.hasClass("oe_structure")) { - $parent.remove(); - } - $parent = $nextParent; - } - if ($parent.children().length === 0 && $parent.text().trim() === "" && !$parent.hasClass("oe_structure")) { - _.defer(function () { - $parent.data("snippet-editor").on_remove(); - }); - } - } - - // clean editor if they are image or table in deleted content - $(".note-control-selection").hide(); - $('.o_table_handler').remove(); - - this.buildingBlock.$el.trigger("snippet-removed"); - - return false; + /** + * Called when asked to clean the DOM for save. Should technically not be + * used but used by tests. + * + * @private + */ + _onCleanForSaveDemand: function (ev) { + this.cleanForSave(); }, - - /* - * drop_and_build_snippet - * This method is called just after that a thumbnail is drag and dropped into a drop zone - * (after the insertion of this.$body, if this.$body exists) - */ - drop_and_build_snippet: function () { - for (var i in this.styles) { - this.styles[i].drop_and_build_snippet(); - } + /** + * Called when a child editor asks to deactivate the current snippet + * overlay. + * + * @private + */ + _onDeactivateSnippet: function () { + this._activateSnippet(false); }, - - /* on_focus - * This method is called when the user click inside the snippet in the dom - */ - on_focus: function () { - this._on_focus_blur(true); + /** + * Called when a snippet has moved in the page. + * + * @todo technically, as a snippet has been moved, all editors should be + * destroyed as their snippet options may not correspond to their + * selector anymore. However this should rarely (maybe never ?) be the case, + * so we might not want to do this as it would slow the editor. + * + * @private + * @param {OdooEvent} ev + */ + _onDragAndDropStop: function (ev) { + this._activateSnippet(ev.data.$snippet); }, - - /* on_focus - * This method is called when the user click outside the snippet in the dom, after a focus - */ - on_blur: function () { - this._on_focus_blur(false); + /** + * Called when a snippet editor asked to disable itself and to enable its + * parent instead. + * + * @private + * @param {OdooEvent} ev + */ + _onGoToParent: function (ev) { + ev.stopPropagation(); + this._activateSnippet(ev.data.$snippet.parent()); }, - - _on_focus_blur: function (focus) { - var do_action = (focus ? _do_action_focus : _do_action_blur); - - // Attach own and parent options on the current overlay - var $style_button = this.$overlay.find(".oe_options"); - var $ul = $style_button.find("ul:first"); - var $headers = $ul.find(".dropdown-header:data(editor)"); - _.each($headers, (function (el) { - var $el = $(el); - var styles = _.values($el.data("editor").styles); - if ($el.data("editor") !== this) { - styles = _.filter(styles, function (option) { return !option.preventChildPropagation; }); - } - - var count = 0; - _.each(_.sortBy(styles, "__order").reverse(), function (style) { - if (do_action(style, $el)) { - count++; - } - }); - $el.toggleClass("hidden", count === 0); - }).bind(this)); - - // Activate the overlay - $style_button.toggleClass("hidden", $ul.children(":not(.o_main_header):not(.divider):not(.hidden)").length === 0); - this.$overlay.toggleClass("oe_active", !!focus); - - function _do_action_focus(style, $dest) { - style.$el.insertAfter($dest); - style.on_focus(); - return (style.$el.length > 0); - } - function _do_action_blur(style, $dest) { - style.$el.detach(); - style.on_blur(); - return false; - } + /** + * Called when a snippet is removed -> checks if there is draggable snippets + * to enable/disable as the DOM changed. + * + * @private + */ + _onSnippetRemoved: function () { + this._disableUndroppableSnippets(); }, }); -/** - * Add the ability on the main editor class to instantiate the snippet editor component - */ -editor.Class.include({ - init: function () { - var self = this; - var res = this._super.apply(this, arguments); - var $editable = this.rte.editable(); - this.buildingBlock = new data.Class(this, $editable); - this.buildingBlock.on("snippets:ready", this, function () { - self.trigger("snippets:ready"); - }); - return res; - }, - start: function () { - this.buildingBlock.insertAfter(this.$el); - this.rte.editable().find("*").off('mousedown mouseup click'); - return this._super.apply(this, arguments); - }, - save: function () { - this.buildingBlock.clean_for_save(); - return this._super.apply(this, arguments); - }, -}); - -/** - * Add the ability the restart the animations - */ -editor.Class.include({ - start: function () { - return this._super.apply(this, arguments).then(function () { - animation.start(true); - }); - }, -}); - -return data; +return { + Class: SnippetsMenu, + Editor: SnippetEditor, + globalSelector: globalSelector, +}; }); diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 68301312cd5..ba4ab0b60ed 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -1,993 +1,1311 @@ odoo.define('web_editor.snippets.options', function (require) { - 'use strict'; +'use strict'; - var ajax = require('web.ajax'); - var Class = require('web.Class'); - var core = require('web.core'); - var Dialog = require('web.Dialog'); - var base = require('web_editor.base'); - var editor = require('web_editor.editor'); - var widget = require('web_editor.widget'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var weContext = require('web_editor.context'); +var widget = require('web_editor.widget'); - var qweb = core.qweb; - var _t = core._t; +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 a': '_onLinkEnter', + 'click a': '_onLinkClick', + 'mouseleave': '_onMouseleave', + 'mouseleave ul': '_onMouseleave', + }, + /** + * When editing a snippet, its options are shown alongside the ones of its + * parent snippets. The parent options are only shown if the following flag + * is set to false (default). + */ + preventChildPropagation: false, /** - * The SnippetOption class handles one option for one snippet. The registry returned by - * this module contains the names of the specialized SnippetOption which can be reference - * thanks to the data-js key of the full options web_editor template. + * The option `$el` is supposed to be the associated
  • element in the + * options dropdown. 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 */ - var SnippetOption = Class.extend({ - init: function (BuildingBlock, editor, $target, option_id) { - this.buildingBlock = BuildingBlock; - this.editor = editor; - this.$target = $target; + init: function (parent, $target, $overlay) { + this._super.apply(this, arguments); + this.$target = $target; + this.$overlay = $overlay; + this.__methodNames = []; + }, + /** + * Called when the option is initialized (i.e. the parent edition overlay is + * shown for the first time). + * + * @override + */ + start: function () { + this.data = this.$el.data(); + 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 () {}, - this.option = option_id; - var option = this.buildingBlock.templateOptions[option_id]; - this.$el = option.$el.children("li").clone(); - this.data = option.$el.data(); + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- - this.$overlay = this.$target.data('overlay') || $(); - }, + /** + * 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 subdropdown with each
  • 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 ($li.data('selectClass')) + * @param {jQuery} $li - the related DOMElement option + */ + selectClass: function (previewMode, value, $li) { + var $lis = this.$el.find('[data-select-class]').addBack('[data-select-class]'); + var classes = $lis.map(function () {return $(this).data('selectClass');}).get().join(' '); - // helper for this.$target.find - $: function () { - return this.$target.find.apply(this.$target, arguments); - }, - - _bind_li_menu: function () { - this.$el.filter("li:hasData").find('a:first') - .off('mouseenter click') - .on('mouseenter click', _.bind(_mouse, this)); - - this.$el - .off('mouseenter click', "li:hasData a") - .on('mouseenter click', "li:hasData a", _.bind(_mouse, this)); - - this.$el.off('mouseleave') - .on('mouseleave', _.bind(this.reset, this)); - - this.$el.off('mouseleave', "ul") - .on('mouseleave', "ul", _.bind(this.reset, this)); - - this.methodsToReset = []; - - function _mouse(event) { - var $next = $(event.currentTarget).parent(); - - // triggers preview or apply methods if a menu item has been clicked - this.select(event.type === "click" ? "click" : "over", $next); - if (event.type === 'click') { - this.set_active(); - this.$target.trigger("snippet-option-change", [this]); - } else { - this.$target.trigger("snippet-option-preview", [this]); - } - } - }, - - /** - * The set_active method tweaks the option dropdown to show the selected value - * according to the state of the $target the option customizes. - */ - set_active: function () { - var self = this; - this.$el.find('[data-toggle_class], [data-select_class]') - .addBack('[data-toggle_class], [data-select_class]') - .removeClass("active") - .filter(function () { - var $elem = $(this); - var className = $elem.data("toggle_class") || $elem.data("select_class"); - return self.$target.hasClass(className); - }) - .addClass("active"); - }, - - start: function () { - this.set_active(); - this.$target.on('snippet-option-reset', _.bind(this.set_active, this)); - this._bind_li_menu(); - }, - - on_focus : function () { - this._bind_li_menu(); - }, - - on_blur : function () {}, - on_clone: function ($clone) {}, - on_move: function () {}, - on_remove: function () {}, - - drop_and_build_snippet: function () {}, - - reset: function () { - var self = this; - var lis = this.$el.find("li.active").addBack('.active').get(); - _.each(lis.reverse(), function (li) { - var $li = $(li); - for (var k in self.methodsToReset) { - var method = self.methodsToReset[k]; - if ($li.closest('[data-' + method + ']').length) { - delete self.methodsToReset[k]; - } - } - self.select("reset", $li); - }); - - for (var k in this.methodsToReset) { - var method = this.methodsToReset[k]; - if (method) { - this[method]("reset", null); - } - } - this.methodsToReset = []; - this.$target.trigger("snippet-option-reset", [this]); - }, - - /** - * Handle action type on a particuler $li (sub)option of the option. - * @param type action type to handle ("click", "over", "reset") - * @param $li the suboption DOM element which is concerned by the action - */ - select: function (type, $li) { - // (sub)options can say they respond to only one action type - if ($li.data('only') && type !== $li.data('only')) { - return; - } - // If the type is click, the user selected the option for good (so reset and record action) - if (type === "click") { - this.reset(); - this.buildingBlock.getParent().rte.historyRecordUndo(this.$target); - } - - // Search for methods (data-...) (i.e. data-toggle_class) on the selected (sub)option and its parents - var el = $li[0]; - var methods = []; - do { - var m = _.omit(el.dataset, ["oeId", "oeModel", "oeField", "oeXpath", "oeSourceId", "only"]); - if (_.keys(m).length) { - methods.push([el, m]); - } - el = el.parentNode; - } while (this.$el.parent().has(el).length); - - // Call the found method in the right order (parents -> child) and save them for later reset - var self = this; - _.each(methods.reverse(), function (data) { - var $el = $(data[0]); - var methods = data[1]; - - for (var k in methods) { - if (self[k]) { - if (type !== "reset" && self.methodsToReset.indexOf(k) === -1) { - self.methodsToReset.push(k); - } - self[k](type, methods[k], $el); - } else { - console.error("'" + self.option + "' snippet option have not method '" + k + "'"); - } - } - }); - }, - - /** - * Default option methods toggle_class and selected_class handle the two behavior of having a list - * of option classes to toggle or not and having a list of option classes where only one must be chosen. - */ - toggle_class: function (type, value, $li) { - var $lis = this.$el.find("[data-toggle_class]").addBack("[data-toggle_class]"); - - function map($lis) { - return $lis.map(function () {return $(this).data("toggle_class");}).get().join(" "); - } - var classes = map($lis); - var active_classes = map($lis.filter('.active, :has(.active)')); - - this.$target.removeClass(classes).addClass(active_classes); - if (type !== 'reset') { - this.$target.toggleClass(value); - } - }, - select_class: function (type, value, $li) { - var $lis = this.$el.find("[data-select_class]").addBack("[data-select_class]"); - - var classes = $lis.map(function () {return $(this).data("select_class");}).get().join(" "); - - this.$target.removeClass(classes); - if (value) { - this.$target.addClass(value); - } - }, - - /** - * Helper method to evaluate the value expression directly as a function - */ - eval: function (type, value, $li) { - var fn = new Function("node", "type", "value", "$li", value); - fn.call(this, this, type, value, $li); - }, - - clean_for_save: editor.dummy - }); - var registry = {}; - - /* ----- default options ---- */ - - registry.marginAndResize = SnippetOption.extend({ - preventChildPropagation: true, - - start: function () { - var self = this; - this._super(); - - var resize_values = this.getSize(); - if (resize_values.n) this.$overlay.find(".oe_handle.n").removeClass("readonly"); - if (resize_values.s) this.$overlay.find(".oe_handle.s").removeClass("readonly"); - if (resize_values.e) this.$overlay.find(".oe_handle.e").removeClass("readonly"); - if (resize_values.w) this.$overlay.find(".oe_handle.w").removeClass("readonly"); - if (resize_values.size) this.$overlay.find(".oe_handle.size").removeClass("readonly"); - - var $auto_size = this.$overlay.find(".oe_handle.size .auto_size"); - var $fixed_size = this.$overlay.find(".oe_handle.size .size"); - - var auto_sized = isNaN(parseInt(self.$target.prop("style").height)); - $fixed_size.toggleClass("active", !auto_sized); - $auto_size.toggleClass("active", auto_sized); - - $fixed_size.add(this.$overlay.find(".oe_handle:not(.size)")).on('mousedown', function (event) { - event.preventDefault(); - - var $handle = $(this); - - var resize_values = self.getSize(); - 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'; - } - else if ($handle.hasClass('size')) { - compass = 'size'; - XY = 'Y'; - } - - var resize = resize_values[compass]; - if (!resize) return; - - - if (compass === 'size') { - var offset = self.$target.offset().top; - if (self.$target.css("background").match(/rgba\(0, 0, 0, 0\)/)) { - self.$target.addClass("resize_editor_busy"); - } - } else { - var xy = event['page'+XY]; - var current = resize[2] || 0; - var margin_dir = {s:'bottom', n: 'top', w: 'left', e: 'right'}[compass]; - var real_margin = parseInt(self.$target.css('margin-'+margin_dir)); - _.each(resize[0], function (val, key) { - if (self.$target.hasClass(val)) { - current = key; - } else if (resize[1][key] === real_margin) { - 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'); - } - - self.buildingBlock.editor_busy = true; - - var cursor = $handle.css("cursor")+'-important'; - var $body = $(document.body); - $body.addClass(cursor); - - var body_mousemove = function (event) { - event.preventDefault(); - if (compass === 'size') { - var dy = event.pageY-offset; - dy = dy - dy%resize; - if (dy <= 0) dy = resize; - self.$target.css("height", dy+"px"); - self.$target.css("overflow", "hidden"); - self.on_resize(compass, null, dy); - self.buildingBlock.cover_target(self.$overlay, self.$target); - return; - } - var dd = event['page'+XY] - xy + resize[1][begin]; - var next = current+1 === resize[1].length ? current : (current+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.on_resize(compass, beginClass, current); - self.buildingBlock.cover_target(self.$overlay, self.$target); - self.adapt_margin_preview_size(); - $handle.addClass('oe_handle_change'); - } - }; - - var body_mouseup = function () { - $body.unbind('mousemove', body_mousemove); - $body.unbind('mouseup', body_mouseup); - $body.removeClass(cursor); - setTimeout(function () { - self.buildingBlock.editor_busy = false; - if (begin !== current) { - self.buildingBlock.getParent().rte.historyRecordUndo(self.$target, 'resize_' + XY); - } - },0); - self.$target.removeClass("resize_editor_busy"); - $handle.removeClass('oe_handle_change'); - - if (compass === "size") { - $fixed_size.addClass("active"); - $auto_size.removeClass("active"); - } else { - self._highlight_margins_preview(300); - } - }; - $body.mousemove(body_mousemove); - $body.mouseup(body_mouseup); - }); - $auto_size.on('click', function () { - self.$target.css("height", ""); - self.$target.css("overflow", ""); - self.buildingBlock.getParent().rte.historyRecordUndo(self.$target, 'resize_Y'); - self.buildingBlock.cover_target(self.$overlay, self.$target); - - $fixed_size.removeClass("active"); - $auto_size.addClass("active"); - - return false; - }); - }, - getSize: function () { - this.grid = {}; - return this.grid; - }, - - on_focus : function () { - this._super(); - this.change_cursor(); - this.adapt_margin_preview_size(); - }, - - change_cursor : function () { - var _class = this.$target.attr("class") || ""; - var $handle_s = this.$overlay.find('.oe_handle.s'); - var $handle_n = this.$overlay.find('.oe_handle.n'); - var $handle_w = this.$overlay.find('.oe_handle.w'); - - var col = _class.match(/col-md-([0-9-]+)/i); - col = col ? +col[1] : 0; - - var offset = _class.match(/col-md-offset-([0-9-]+)/i); - offset = offset ? +offset[1] : 0; - - var overlay_class = this.$overlay.attr("class").replace(/(^|\s+)block-[^\s]*/gi, ''); - if (col+offset >= 12) overlay_class+= " block-e-right"; - if (col === 1) overlay_class+= " block-w-right block-e-left"; - if (offset === 0) overlay_class+= " block-w-left"; - $handle_w.toggleClass('oe_handle_centered', offset > 0).toggleClass('o_handle_edited', offset >= 1 ); - - var mb = _class.match(/mb([0-9-]+)/i); - mb = mb ? +mb[1] : parseInt(this.$target.css('margin-bottom')); - if (mb >= 128) overlay_class+= " block-s-bottom"; - else if (!mb) overlay_class+= " block-s-top"; - $handle_s.toggleClass('oe_handle_centered', mb >= 32).toggleClass('o_handle_edited', mb > 0 ); - - var mt = _class.match(/mt([0-9-]+)/i); - mt = mt ? +mt[1] : parseInt(this.$target.css('margin-top')); - if (mt >= 128) overlay_class+= " block-n-top"; - else if (!mt) overlay_class+= " block-n-bottom"; - $handle_n.toggleClass('oe_handle_centered', mt >= 32).toggleClass('o_handle_edited', mt > 0 ); - - this.$overlay.attr("class", overlay_class); - }, - - adapt_margin_preview_size : function () { - var self = this, - ml = this.$target.css('margin-left'); - _.each(this.$overlay.find(".oe_handle.n, .oe_handle.s"), function (handle) { - var $handle = $(handle); - var direction = $handle.hasClass('n') ? 'top': 'bottom'; - $handle.height(self.$target.css('margin-' + direction )); - }); - this.$overlay.find(".oe_handle.w").css({ - width: ml, - left: '-' + ml - }); - }, - - _highlight_margins_preview: function (timing) { - var handlers = this.$overlay.find(".oe_handle.n, .oe_handle.s, .oe_handle.w"); - handlers.addClass('oe_handle_active').delay(timing).queue(function(){ - handlers.removeClass('oe_handle_active').dequeue(); - }); - }, - - /* on_resize - * called when the box is resizing and the class change, before the cover_target - * @compass: resize direction : 'n', 's', 'e', 'w' - * @beginClass: attributes class at the begin - * @current: curent increment in this.grid - */ - on_resize: function () { - this.change_cursor(); + 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 subdropdown with each
  • having a `data-toggle-class` + * value allowing to toggle the associated class. + * + * @see this.selectClass + */ + toggleClass: function (previewMode, value, $li) { + var $lis = this.$el.find('[data-toggle-class]').addBack('[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(' '); - registry["margin-y"] = registry.marginAndResize.extend({ - preventChildPropagation: true, + this.$target.removeClass(classes).addClass(activeClasses); + if (value && previewMode !== 'reset') { + this.$target.toggleClass(value); + } + }, - getSize: function () { - this.grid = this._super(); - var grid = [0,4,8,16,32,48,64,92,128]; - this.grid = { - // list of class (Array), grid (Array), default value (INT) - n: [_.map(grid, function (v) {return 'mt'+v;}), grid], - s: [_.map(grid, function (v) {return 'mb'+v;}), grid], - // INT if the user can resize the snippet (resizing per INT px) - size: null - }; - return this.grid; - }, - }); - - registry.resize = registry.marginAndResize.extend({ - preventChildPropagation: true, - - getSize: function () { - this.grid = this._super(); - this.grid.size = 8; - return this.grid; - }, - }); + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- /** - * The colorpicker option is designed to change the background color class of a snippet. This class change the - * default background color and text color of the snippet content. + * Override the helper method to search inside the $target element instead + * of the dropdown
  • element. + * + * @override */ - registry.colorpicker = SnippetOption.extend({ - start: function () { - var self = this; - var res = this._super.apply(this, arguments); + $: 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]); + }, - if (!this.$el.find('.colorpicker').length) { - var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); - var $clpicker = $(qweb.render('web_editor.colorpicker')); + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- - // 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); - } + /** + * Reactivate the options that were activated before previews. + */ + _reset: function () { + var self = this; + var $actives = this.$el.find('.active').addBack('.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} $li - the related DOMElement option + */ + _select: function (previewMode, $li) { + var self = this; - var $toggles = $pt.find('.o_colorpicker_section_menu'); - var $tabs = $pt.find('.o_colorpicker_section_tabs'); + // Options can say they respond to strong choice + if (previewMode && ($li.data('noPreview') || $li.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}); + } - // Remove excluded palettes - _.each(excluded, function (exc) { - $clpicker.find('[data-name="' + exc + '"]').remove(); - }); + // Search for methods (data-...) (i.e. data-toggle-class) on the + // selected (sub)option and its parents + var el = $li[0]; + var methods = []; + do { + methods.push([el, el.dataset]); + el = el.parentNode; + } while (this.$el.parent().has(el).length); - var $sections = $clpicker.find('.o_colorpicker_section'); + // Call the found method in the right order (parents -> child) + _.each(methods.reverse(), function (data) { + var $el = $(data[0]); + var methods = data[1]; - if ($sections.length > 1) { // Multi-palette layout - $sections.each(function () { - var $section = $(this); - var id = 'o_palette_' + $section.data('name') + _.uniqueId(); - - var $li = $('
  • ') - .append($('', {href: '#' + id}) - .append($('', {'class': $section.data('iconClass') || '', html: $section.data('iconContent') || ''}))); - $toggles.append($li); - - $tabs.append($section.addClass('tab-pane').attr('id', id)); - }); - - // If a default palette is defined, make it active - if (this.data.paletteDefault) { - var $palette_def = $tabs.find('div[data-name="' + self.data.paletteDefault + '"]'); - var pos = $tabs.find('> div').index($palette_def); - - $toggles.children('li').eq(pos).addClass('active'); - $palette_def.addClass('active'); - } else { - $toggles.find('li').first().addClass('active'); - $tabs.find('div').first().addClass('active'); + _.each(methods, function (value, methodName) { + if (self[methodName]) { + if (previewMode === true) { + self.__methodNames.push(methodName); } - - $toggles.on('click mouseover', '> li > a', function (e) { - e.preventDefault(); - e.stopPropagation(); - $(this).tab('show'); - }); - } else if ($sections.length === 1) { // Unique palette layout - $tabs.addClass('o_unique_palette').append($sections.addClass('tab-pane active')); - } else { - $toggles.parent().empty().append($clpicker); + self[methodName](previewMode, value, $el); } + }); + }); + this.__methodNames = _.uniq(this.__methodNames); - this.$el.find('li').append($pt); + if (!previewMode) { + this._setActive(); + } + }, + /** + * 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], [data-select-class]') + .addBack('[data-toggle-class], [data-select-class]') + .removeClass('active') + .filter(function () { + var $elem = $(this); + var className = $elem.data('toggleClass') || $elem.data('selectClass'); + return self.$target.hasClass(className); + }) + .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 $li = $(ev.currentTarget).parent(':hasData'); + if (!$li.length) { + return; + } + this.__click = false; + this._select(true, $li); + 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 $li = $(ev.currentTarget).parent(':hasData'); + if (!$li.length) { + return; + } + ev.preventDefault(); + this.__click = true; + this._select(false, $li); + 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.marginAndResize = SnippetOption.extend({ + preventChildPropagation: true, + + /** + * @override + */ + start: function () { + var self = this; + this._super(); + + var resize_values = this._getSize(); + if (resize_values.n) this.$overlay.find('.oe_handle.n').removeClass('readonly'); + if (resize_values.s) this.$overlay.find('.oe_handle.s').removeClass('readonly'); + if (resize_values.e) this.$overlay.find('.oe_handle.e').removeClass('readonly'); + if (resize_values.w) this.$overlay.find('.oe_handle.w').removeClass('readonly'); + if (resize_values.size) this.$overlay.find('.oe_handle.size').removeClass('readonly'); + + var $auto_size = this.$overlay.find('.oe_handle.size .auto_size'); + var $fixed_size = this.$overlay.find('.oe_handle.size .size'); + + var auto_sized = isNaN(parseInt(self.$target.prop('style').height)); + $fixed_size.toggleClass('active', !auto_sized); + $auto_size.toggleClass('active', auto_sized); + + $fixed_size.add(this.$overlay.find('.oe_handle:not(.size)')).on('mousedown', function (event) { + event.preventDefault(); + + var $handle = $(this); + + var resize_values = self._getSize(); + var compass = false; + var XY = false; + if ($handle.hasClass('n')) { + compass = 'n'; + XY = 'Y'; } - if (this.$el.data('area')) { - this.$target = this.$target.find(this.$el.data('area')); - this.$el.removeData('area').removeAttr('area'); + 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'; + } + else if ($handle.hasClass('size')) { + compass = 'size'; + XY = 'Y'; } - var classes = []; - this.$el.find(".colorpicker button").each(function () { - var $color = $(this); - if (!$color.data("color")) { + var resize = resize_values[compass]; + if (!resize) return; + + + if (compass === 'size') { + var offset = self.$target.offset().top; + } else { + var xy = event['page'+XY]; + var current = resize[2] || 0; + var margin_dir = {s:'bottom', n: 'top', w: 'left', e: 'right'}[compass]; + var real_margin = parseInt(self.$target.css('margin-'+margin_dir)); + _.each(resize[0], function (val, key) { + if (self.$target.hasClass(val)) { + current = key; + } else if (resize[1][key] === real_margin) { + 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 = $(document.body); + $body.addClass(cursor); + + var body_mousemove = function (event) { + event.preventDefault(); + if (compass === 'size') { + var dy = event.pageY-offset; + dy = dy - dy%resize; + if (dy <= 0) dy = resize; + self.$target.css('height', dy+'px'); + self.$target.css('overflow', 'hidden'); + self._onResize(compass, null, dy); + self.trigger_up('cover_update'); return; } + var dd = event['page'+XY] - xy + resize[1][begin]; + var next = current+1 === resize[1].length ? current : (current+1); + var prev = current ? (current-1) : 0; - var className = 'bg-' + $color.data('color'); - $color.addClass(className); - if (self.$target.hasClass(className)) { - self.color = className; - $color.addClass("selected"); + 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; } - classes.push(className); - }); - this.classes = classes.join(" "); - this.bind_events(); - return res; - }, - bind_events: function () { - var self = this; - var $colors = this.$el.find(".colorpicker button"); - $colors - .mouseenter(function (e) { - self.$target.removeClass(self.classes); - var color = $(this).data("color"); - if (color) { - self.$target.addClass('bg-' + color); - } - self.$target.trigger("background-color-event", e.type); - }) - .mouseleave(function (e) { - self.$target.removeClass(self.classes); - var $selected = $colors.filter(".selected"); - var color = $selected.length && $selected.data("color"); - if (color) { - self.$target.addClass('bg-' + color); - } - self.$target.trigger("background-color-event", e.type); - }) - .click(function (e) { - $colors.removeClass("selected"); - $(this).addClass("selected"); - self.$target.closest(".o_editable").trigger("content_changed"); - self.$target.trigger("background-color-event", e.type); - }); + if (change) { + self._onResize(compass, beginClass, current); + self.trigger_up('cover_update'); + self._adaptMarginsPreviews(); + $handle.addClass('oe_handle_change'); + } + }; - this.$el.find('.note-color-reset').on('click', function () { - self.$target.removeClass(self.classes); - $colors.removeClass("selected"); + var body_mouseup = function () { + $body.unbind('mousemove', body_mousemove); + $body.unbind('mouseup', body_mouseup); + $body.removeClass(cursor); + setTimeout(function () { + if (begin !== current) { + self.trigger_up('request_history_undo_record', { + $target: self.$target, + event: 'resize_' + XY, + }); + } + },0); + $handle.removeClass('oe_handle_change'); + + if (compass === 'size') { + $fixed_size.addClass('active'); + $auto_size.removeClass('active'); + } else { + self._highlightMarginsPreviews(); + } + }; + $body.mousemove(body_mousemove); + $body.mouseup(body_mouseup); + }); + $auto_size.on('click', function () { + self.$target.css('height', ''); + self.$target.css('overflow', ''); + self.trigger_up('request_history_undo_record', { + $target: self.$target, + event: 'resize_Y', }); - } - }); + self.trigger_up('cover_update'); + + $fixed_size.removeClass('active'); + $auto_size.addClass('active'); + + return false; + }); + }, + /** + * @override + */ + onFocus : function () { + this._updateCursor(); + this._adaptMarginsPreviews(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- /** - * The background option is designed to change the background image of a snippet. + * Adapts the margin handles to fit the left, top and bottom margins. + * + * @private */ - registry.background = SnippetOption.extend({ - start: function () { - var res = this._super.apply(this, arguments); - this.bind_bg_events(); - return res; - }, + _adaptMarginsPreviews: function () { + var self = this; + var ml = this.$target.css('margin-left'); + _.each(this.$overlay.find(".oe_handle.n, .oe_handle.s"), function (handle) { + var $handle = $(handle); + var direction = $handle.hasClass('n') ? 'top': 'bottom'; + $handle.height(self.$target.css('margin-' + direction)); + }); + this.$overlay.find(".oe_handle.w").css({ + width: ml, + left: '-' + ml, + }); + }, + /** + * Returns an object whose keys indicate the different editable dimensions. + * + * @private + * @returns {Object} + */ + _getSize: function () { + this.grid = {}; + return this.grid; + }, + /** + * Highlights the margins previews for a while. + * + * @private + */ + _highlightMarginsPreviews: function () { + var $handlers = this.$overlay.find(".oe_handle.n, .oe_handle.s, .oe_handle.w"); + $handlers.addClass('oe_handle_active').delay(300).queue(function () { + $handlers.removeClass('oe_handle_active').dequeue(); + }); + }, + /** + * Called when the snippet is being resized and its classes changes. + * + * @private + * @param {string} compass - resize direction ('n', 's', 'e' or 'w') + * @param {?} beginClass - attributes class at the beginning + * @param {?} current - curent increment in this.grid + */ + _onResize: function (compass, beginClass, current) { + this._updateCursor(); + }, + /** + * Adapts the resize handles according to the classes and dimensions. + * + * @private + */ + _updateCursor : function () { + var _class = this.$target.attr('class') || ''; + var $handle_s = this.$overlay.find('.oe_handle.s'); + var $handle_n = this.$overlay.find('.oe_handle.n'); + var $handle_w = this.$overlay.find('.oe_handle.w'); - bind_bg_events: function () { - this.$target.off(".background-option") - .on("background-color-event.background-option", (function (e, type) { - e.stopPropagation(); - if (e.currentTarget !== e.target) return; - this.$el.find("li[data-background=''] > a").trigger(type); - }).bind(this)); - }, - background: function (type, value, $li) { - if (value && value.length) { - this.$target.css("background-image", "url(" + value + ")"); - this.$target.addClass("oe_img_bg"); - } else { - this.$target.css("background-image", ""); - this.$target.removeClass("oe_img_bg oe_custom_bg"); + var col = _class.match(/col-md-([0-9-]+)/i); + col = col ? +col[1] : 0; + + var offset = _class.match(/col-md-offset-([0-9-]+)/i); + offset = offset ? +offset[1] : 0; + + var overlay_class = this.$overlay.attr('class').replace(/(^|\s+)block-[^\s]*/gi, ''); + if (col+offset >= 12) overlay_class+= ' block-e-right'; + if (col === 1) overlay_class+= ' block-w-right block-e-left'; + if (offset === 0) overlay_class+= ' block-w-left'; + $handle_w.toggleClass('oe_handle_centered', offset > 0).toggleClass('o_handle_edited', offset >= 1 ); + + var mb = _class.match(/mb([0-9-]+)/i); + mb = mb ? +mb[1] : parseInt(this.$target.css('margin-bottom')); + if (mb >= 128) overlay_class+= ' block-s-bottom'; + else if (!mb) overlay_class+= ' block-s-top'; + $handle_s.toggleClass('oe_handle_centered', mb >= 32).toggleClass('o_handle_edited', mb > 0 ); + + var mt = _class.match(/mt([0-9-]+)/i); + mt = mt ? +mt[1] : parseInt(this.$target.css('margin-top')); + if (mt >= 128) overlay_class+= ' block-n-top'; + else if (!mt) overlay_class+= ' block-n-bottom'; + $handle_n.toggleClass('oe_handle_centered', mt >= 32).toggleClass('o_handle_edited', mt > 0 ); + + this.$overlay.attr('class', overlay_class); + }, +}); + +/** + * Handles the edition of margin-top and margin-bottom. + */ +registry['margin-y'] = registry.marginAndResize.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + this.grid = this._super(); + var grid = [0,4,8,16,32,48,64,92,128]; + this.grid = { + // list of class (Array), grid (Array), default value (INT) + n: [_.map(grid, function (v) {return 'mt'+v;}), grid], + s: [_.map(grid, function (v) {return 'mb'+v;}), grid], + // INT if the user can resize the snippet (resizing per INT px) + size: null + }; + return this.grid; + }, +}); + +/** + * Handles the edition of snippet's height. + */ +registry.resize = registry.marginAndResize.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + this.grid = this._super(); + this.grid.size = 8; + 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', + }), + + /** + * @override + */ + start: function () { + var self = this; + var res = this._super.apply(this, arguments); + + if (!this.$el.find('.colorpicker').length) { + var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); + var $clpicker = $(qweb.render('web_editor.colorpicker')); + + // Retrieve excluded palettes list + var excluded = []; + if (this.data.paletteExclude) { + excluded = this.data.paletteExclude.replace(/ /g, '').split(','); } - }, - select_class: function (type, value, $li) { - this.background(type, '', $li); - this._super(type, value ? (value + " oe_img_bg") : value, $li); - }, - choose_image: function (type, value, $li) { - if(type !== "click") { + // Apply a custom title if specified + if (this.data.paletteTitle) { + $pt.find('.note-palette-title').text(this.data.paletteTitle); + } + + var $toggles = $pt.find('.o_colorpicker_section_menu'); + var $tabs = $pt.find('.o_colorpicker_section_tabs'); + + // Remove excluded palettes + _.each(excluded, function (exc) { + $clpicker.find('[data-name="' + exc + '"]').remove(); + }); + + var $sections = $clpicker.find('.o_colorpicker_section'); + + if ($sections.length > 1) { // Multi-palette layout + $sections.each(function () { + var $section = $(this); + var id = 'o_palette_' + $section.data('name') + _.uniqueId(); + + var $li = $('
  • ') + .append($('', {href: '#' + id}) + .append($('', {'class': $section.data('iconClass') || '', html: $section.data('iconContent') || ''}))); + $toggles.append($li); + + $tabs.append($section.addClass('tab-pane').attr('id', id)); + }); + + // If a default palette is defined, make it active + if (this.data.paletteDefault) { + var $palette_def = $tabs.find('div[data-name="' + self.data.paletteDefault + '"]'); + var pos = $tabs.find('> div').index($palette_def); + + $toggles.children('li').eq(pos).addClass('active'); + $palette_def.addClass('active'); + } else { + $toggles.find('li').first().addClass('active'); + $tabs.find('div').first().addClass('active'); + } + + $toggles.on('click mouseover', '> li > a', function (e) { + e.preventDefault(); + e.stopPropagation(); + $(this).tab('show'); + }); + } else if ($sections.length === 1) { // Unique palette layout + $tabs.addClass('o_unique_palette').append($sections.addClass('tab-pane active')); + } else { + $toggles.parent().empty().append($clpicker); + } + + this.$el.find('li').append($pt); + } + if (this.$el.data('area')) { + this.$target = this.$target.find(this.$el.data('area')); + this.$el.removeData('area').removeAttr('area'); + } + + var classes = []; + this.$el.find('.colorpicker button').each(function () { + var $color = $(this); + if (!$color.data('color')) { return; } - // Put fake image in the DOM, edit it and use it as background-image - var $image = $("", {"class": "hidden", "src": value}).appendTo(this.$target); - - var _editor = new widget.MediaDialog(null, {}, null, $image[0]).open(); - _editor.$('[href="#editor-media-video"], [href="#editor-media-icon"]').addClass('hidden'); - - _editor.on('save', this, function () { - var value = $image.attr("src"); - this.background(type, value, $li); - this.$target.addClass("oe_custom_bg"); - this.set_active(); - this.$target.trigger("snippet-option-change", [this]); - }); - _editor.on('closed', this, function () { - $image.remove(); - }); - }, - set_active: function () { - this._super.apply(this, arguments); - - var src = this.$target.css("background-image").replace(/url\(['"]*|['"]*\)|^none$/g, ""); - if (this.$target.hasClass('oe_custom_bg')) { - this.$el.find('li[data-choose_image]').data("background", src).attr("data-background", src); + var className = 'bg-' + $color.data('color'); + $color.addClass(className); + if (self.$target.hasClass(className)) { + self.color = className; + $color.addClass('selected'); } + classes.push(className); + }); + this.classes = classes.join(' '); - this.$el.find('li[data-background]') - .removeClass("active") - .filter(function () { - var bgOption = $(this).data("background"); - return (bgOption === "" && src === "" || bgOption !== "" && src.indexOf(bgOption) >= 0); - }) - .addClass("active"); - } - }); + return res; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- /** - * The background_position option is designed to change the background image position of a snippet. + * Called when a color button is clicked -> confirm the preview. + * + * @private + * @param {Event} ev */ - registry.background_position = SnippetOption.extend({ - start: function () { - this._super.apply(this, arguments); - this.on_focus(); - var self = this; - this.$target.on("snippet-option-change", function () { - self.on_focus(); - }); - }, - on_focus: function () { - this._super.apply(this, arguments); - this.$el.toggleClass('hidden', this.$target.css('background-image') === 'none'); - }, - background_position: function (type, value, $li) { - if (type != 'click') { return; } - var self = this; + _onColorButtonClick: function (ev) { + this.$el.find('.colorpicker button.selected').removeClass('selected'); + $(ev.currentTarget).addClass('selected'); + this.$target.closest('.o_editable').trigger('content_changed'); + this.$target.trigger('background-color-event', ev.type); + }, + /** + * Called when a color button is entered -> preview the background color. + * + * @private + * @param {Event} ev + */ + _onColorButtonEnter: function (ev) { + this.$target.removeClass(this.classes); + var color = $(ev.currentTarget).data('color'); + if (color) { + this.$target.addClass('bg-' + color); + } + this.$target.trigger('background-color-event', ev.type); + }, + /** + * Called when a color button is left -> cancel the preview. + * + * @private + * @param {Event} ev + */ + _onColorButtonLeave: function (ev) { + this.$target.removeClass(this.classes); + var $selected = this.$el.find('.colorpicker button.selected'); + var color = $selected.length && $selected.data('color'); + if (color) { + this.$target.addClass('bg-' + color); + } + this.$target.trigger('background-color-event', ev.type); + }, + /** + * Called when the color reset button is clicked -> remove all background + * color classes. + * + * @private + */ + _onColorResetButtonClick: function () { + this.$target.removeClass(this.classes); + this.$el.find('.colorpicker button.selected').removeClass('selected'); + }, +}); - this.previous_state = [this.$target.attr('class'), this.$target.css('background-size'), this.$target.css('background-position')]; +/** + * Handles the edition of snippet's background image. + */ +registry.background = SnippetOption.extend({ + /** + * @override + */ + start: function () { + var res = this._super.apply(this, arguments); + this.bindBackgroundEvents(); + return res; + }, - this.bg_pos = self.$target.css('background-position').split(' '); - this.bg_siz = self.$target.css('background-size').split(' '); + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- - this.modal = new Dialog(null, { - title: _t("Background Image Sizing"), - $content: $(qweb.render('web_editor.dialog.background_position')), - buttons: [ - {text: _t("Ok"), classes: "btn-primary", close: true, click: _.bind(this.save, this)}, - {text: _t("Discard"), close: true, click: _.bind(this.discard, this)}, - ], - }).open(); + /** + * Handles a background change. + * + * @see this.selectClass for parameters + */ + background: function (previewMode, value, $li) { + if (value && value.length) { + this.$target.css('background-image', 'url(' + value + ')'); + this.$target.addClass('oe_img_bg'); + } else { + this.$target.css('background-image', ''); + this.$target.removeClass('oe_img_bg oe_custom_bg'); + } + }, + /** + * @override + */ + selectClass: function (previewMode, value, $li) { + this.background(previewMode, '', $li); + this._super(previewMode, value ? (value + ' oe_img_bg') : value, $li); + }, + /** + * Opens a media dialog to add a custom background image. + * + * @see this.selectClass for parameters + */ + chooseImage: function (previewMode, value, $li) { + // Put fake image in the DOM, edit it and use it as background-image + var $image = $('', {class: 'hidden', src: value}).appendTo(this.$target); - this.modal.opened().then(function () { - // Fetch data form $target - var value = ((self.$target.hasClass('o_bg_img_opt_contain'))? 'contain' : ((self.$target.hasClass('o_bg_img_opt_custom'))? 'custom' : 'cover')); - self.modal.$("> label > input[value=" + value + "]").prop('checked', true); + var _editor = new widget.MediaDialog(this, {}, null, $image[0]).open(); + _editor.$('[href="#editor-media-video"], [href="#editor-media-icon"]').addClass('hidden'); - if(self.$target.hasClass("o_bg_img_opt_repeat")) { - self.modal.$("#o_bg_img_opt_contain_repeat").prop('checked', true); - self.modal.$("#o_bg_img_opt_custom_repeat").val('o_bg_img_opt_repeat'); - } else if (self.$target.hasClass("o_bg_img_opt_repeat_x")) { - self.modal.$("#o_bg_img_opt_custom_repeat").val('o_bg_img_opt_repeat_x'); - } else if (self.$target.hasClass("o_bg_img_opt_repeat_y")) { - self.modal.$("#o_bg_img_opt_custom_repeat").val('o_bg_img_opt_repeat_y'); - } + _editor.on('save', this, function () { + var value = $image.attr('src'); + this.background(previewMode, value, $li); + this.$target.addClass('oe_custom_bg'); + this._setActive(); + this.$target.trigger('snippet-option-change', [this]); + }); + _editor.on('closed', this, function () { + $image.remove(); + }); + }, - if(self.bg_pos.length > 1) { - self.bg_pos = { - x: self.bg_pos[0], - y: self.bg_pos[1], - }; - self.modal.$("#o_bg_img_opt_custom_pos_x").val(self.bg_pos.x.replace('%', '')); - self.modal.$("#o_bg_img_opt_custom_pos_y").val(self.bg_pos.y.replace('%', '')); - } - if(self.bg_siz.length > 1) { - self.modal.$("#o_bg_img_opt_custom_size_x").val(self.bg_siz[0].replace('%', '')); - self.modal.$("#o_bg_img_opt_custom_size_y").val(self.bg_siz[1].replace('%', '')); - } + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- - // Focus Point - self.$focus = self.modal.$(".o_focus_point"); - self.update_pos_information(); + /** + * Attaches events so that when a background-color is set, the background + * image is removed. + */ + bindBackgroundEvents: function () { + this.$target.off('.background-option') + .on('background-color-event.background-option', (function (e, type) { + e.stopPropagation(); + if (e.currentTarget !== e.target) return; + this.$el.find('li[data-background=""] > a').trigger(type); + }).bind(this)); + }, + /** + * @override + */ + setTarget: function () { + this._super.apply(this, arguments); + this.bindBackgroundEvents(); + }, - var img_url = /\(['"]?([^'"]+)['"]?\)/g.exec(self.$target.css('background-image')); - img_url = (img_url && img_url[1]) || ''; - var $img = $('', {'class': 'img img-responsive', src: img_url}); - $img.on('load', function () { - self.bind_img_events($img); - }); - $img.prependTo(self.modal.$(".o_bg_img_opt_object")); + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- - // Bind events - self.modal.$el.on('change', '> label > input', function (e) { - self.modal.$('> .o_bg_img_opt').addClass('o_hidden') - .filter("[data-value=" + e.target.value + "]") - .removeClass('o_hidden'); - }); - self.modal.$el.on('change', 'input, select', function (e) { - self.save(); - }); - self.modal.$("> label > input:checked").trigger('change'); - }); - }, - bind_img_events: function ($img) { - var self = this; + /** + * @override + */ + _setActive: function () { + this._super.apply(this, arguments); - var mousedown = false; - $img.on('mousedown', function (e) { - mousedown = true; - }); - $img.on('mousemove', function (e) { - if (mousedown) { - _update(e); - } - }); - $img.on('mouseup', function (e) { - self.$focus.addClass('o_with_transition'); - _update(e); - setTimeout(function () { - self.$focus.removeClass('o_with_transition'); - }, 200); - mousedown = false; - }); + var src = this.$target.css('background-image').replace(/url\(['"]*|['"]*\)|^none$/g, ''); + if (this.$target.hasClass('oe_custom_bg')) { + this.$el.find('li[data-choose-image]').data('background', src).attr('data-background', src); + } - function _update(e) { - var posX = e.pageX - $(e.target).offset().left; - var posY = e.pageY - $(e.target).offset().top; - self.bg_pos = { - x: clip_value(posX/$img.width()*100).toFixed(2) + '%', - y: clip_value(posY/$img.height()*100).toFixed(2) + '%', - }; - self.update_pos_information(); - self.save(); + this.$el.find('li[data-background]') + .removeClass('active') + .filter(function () { + var bgOption = $(this).data('background'); + return (bgOption === '' && src === '' || bgOption !== '' && src.indexOf(bgOption) >= 0); + }) + .addClass('active'); + } +}); + +/** + * Handles the edition of snippet's background image position. + */ +registry.background_position = SnippetOption.extend({ + xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], + + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + var self = this; + this.$target.on('snippet-option-change', function () { + self.onFocus(); + }); + }, + /** + * @override + */ + onFocus: function () { + this.$el.toggleClass('hidden', this.$target.css('background-image') === 'none'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Opens a Dialog to edit the snippet's backgroung image position. + * + * @see this.selectClass for parameters + */ + backgroundPosition: function (previewMode, value, $li) { + var self = this; + + this.previous_state = [this.$target.attr('class'), this.$target.css('background-size'), this.$target.css('background-position')]; + + this.bg_pos = self.$target.css('background-position').split(' '); + this.bg_siz = self.$target.css('background-size').split(' '); + + this.modal = new Dialog(null, { + title: _t("Background Image Sizing"), + $content: $(qweb.render('web_editor.dialog.background_position')), + buttons: [ + {text: _t("Ok"), classes: 'btn-primary', close: true, click: _.bind(this._saveChanges, this)}, + {text: _t("Discard"), close: true, click: _.bind(this._discardChanges, this)}, + ], + }).open(); + + this.modal.opened().then(function () { + // Fetch data form $target + var value = ((self.$target.hasClass('o_bg_img_opt_contain'))? 'contain' : ((self.$target.hasClass('o_bg_img_opt_custom'))? 'custom' : 'cover')); + self.modal.$('> label > input[value=' + value + ']').prop('checked', true); + + if (self.$target.hasClass('o_bg_img_opt_repeat')) { + self.modal.$('#o_bg_img_opt_contain_repeat').prop('checked', true); + self.modal.$('#o_bg_img_opt_custom_repeat').val('o_bg_img_opt_repeat'); + } else if (self.$target.hasClass('o_bg_img_opt_repeat_x')) { + self.modal.$('#o_bg_img_opt_custom_repeat').val('o_bg_img_opt_repeat_x'); + } else if (self.$target.hasClass('o_bg_img_opt_repeat_y')) { + self.modal.$('#o_bg_img_opt_custom_repeat').val('o_bg_img_opt_repeat_y'); } - function clip_value(value) { + if (self.bg_pos.length > 1) { + self.bg_pos = { + x: self.bg_pos[0], + y: self.bg_pos[1], + }; + self.modal.$('#o_bg_img_opt_custom_pos_x').val(self.bg_pos.x.replace('%', '')); + self.modal.$('#o_bg_img_opt_custom_pos_y').val(self.bg_pos.y.replace('%', '')); + } + if (self.bg_siz.length > 1) { + self.modal.$('#o_bg_img_opt_custom_size_x').val(self.bg_siz[0].replace('%', '')); + self.modal.$('#o_bg_img_opt_custom_size_y').val(self.bg_siz[1].replace('%', '')); + } + + // Focus Point + self.$focus = self.modal.$('.o_focus_point'); + self._updatePosInformation(); + + var img_url = /\(['"]?([^'"]+)['"]?\)/g.exec(self.$target.css('background-image')); + img_url = (img_url && img_url[1]) || ''; + var $img = $('', {class: 'img img-responsive', src: img_url}); + $img.on('load', function () { + self._bindImageEvents($img); + }); + $img.prependTo(self.modal.$('.o_bg_img_opt_object')); + + // Bind events + self.modal.$el.on('change', '> label > input', function (e) { + self.modal.$('> .o_bg_img_opt').addClass('o_hidden') + .filter('[data-value=' + e.target.value + ']') + .removeClass('o_hidden'); + }); + self.modal.$el.on('change', 'input, select', function (e) { + self._saveChanges(); + }); + self.modal.$('> label > input:checked').trigger('change'); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Bind events on the given image so that the users can adapt the focus + * point. + * + * @private + * @param {jQuery} $img + */ + _bindImageEvents: function ($img) { + var self = this; + + var mousedown = false; + $img.on('mousedown', function (e) { + mousedown = true; + }); + $img.on('mousemove', function (e) { + if (mousedown) { + _update(e); + } + }); + $img.on('mouseup', function (e) { + self.$focus.addClass('o_with_transition'); + _update(e); + setTimeout(function () { + self.$focus.removeClass('o_with_transition'); + }, 200); + mousedown = false; + }); + + function _update(e) { + var posX = e.pageX - $(e.target).offset().left; + var posY = e.pageY - $(e.target).offset().top; + self.bg_pos = { + x: clipValue(posX/$img.width()*100).toFixed(2) + '%', + y: clipValue(posY/$img.height()*100).toFixed(2) + '%', + }; + self._updatePosInformation(); + self._saveChanges(); + + function clipValue(value) { return Math.max(0, Math.min(value, 100)); } - }, - update_pos_information: function () { - this.modal.$(".o_bg_img_opt_ui_info .o_x").text(this.bg_pos.x); - this.modal.$(".o_bg_img_opt_ui_info .o_y").text(this.bg_pos.y); - this.$focus.css({ - left: this.bg_pos.x, - top: this.bg_pos.y, - }); - }, - save: function () { - this.clean(); - - var bg_img_size = this.modal.$('> :not(label):not(.o_hidden)').data('value') || 'cover'; - switch (bg_img_size) { - case "cover": - this.$target.css('background-position', this.bg_pos.x + ' ' + this.bg_pos.y); - break; - case "contain": - this.$target.addClass('o_bg_img_opt_contain'); - this.$target.toggleClass('o_bg_img_opt_repeat', this.modal.$("#o_bg_img_opt_contain_repeat").prop("checked")); - break; - case "custom": - this.$target.addClass('o_bg_img_opt_custom'); - var sizeX = this.modal.$("#o_bg_img_opt_custom_size_x").val(); - var sizeY = this.modal.$("#o_bg_img_opt_custom_size_y").val(); - var posX = this.modal.$("#o_bg_img_opt_custom_pos_x").val(); - var posY = this.modal.$("#o_bg_img_opt_custom_pos_y").val(); - this.$target.addClass(this.modal.$("#o_bg_img_opt_custom_repeat").val()) - .css({ - 'background-size': ((sizeX)? sizeX + '%' : 'auto') + " " + ((sizeY)? sizeY + '%' : 'auto'), - 'background-position': ((posX)? posX + '%' : 'auto') + " " + ((posY)? posY + '%' : 'auto'), - }); - break; - } - }, - discard: function () { - this.clean(); - if (this.previous_state) { - this.$target.addClass(this.previous_state[0]).css({ - 'background-size': this.previous_state[1], - 'background-position': this.previous_state[2], - }); - } - }, - clean: function () { - this.$target.removeClass('o_bg_img_opt_contain o_bg_img_opt_custom o_bg_img_opt_repeat o_bg_img_opt_repeat_x o_bg_img_opt_repeat_y') - .css({ - 'background-size': '', - 'background-position': '', - }); - }, - }); - - /* t-field options */ - - registry.many2one = SnippetOption.extend({ - start: function () { - var self = this; - - this.Model = this.$target.data('oe-many2one-model'); - this.ID = +this.$target.data('oe-many2one-id'); - - // create search button and bind search bar - this.$btn = $(qweb.render("web_editor.many2one.button")) - .insertAfter(this.$overlay.find('.oe_options')); - - this.$ul = this.$btn.find("ul"); - this.$search = this.$ul.find('li:first'); - this.$search.find('input').on('mousedown click mouseup keyup keydown', function (e) { - e.stopPropagation(); - }); - - // move menu item - setTimeout(function () { - if (self.$overlay.find('.oe_options').hasClass('hidden')) { - self.$btn.css('height', '0').find('> a').addClass('hidden'); - self.$ul.show().css({ - 'top': '-24px', 'margin': '0', 'padding': '2px 0', 'position': 'relative' - }); - } else { - self.$btn.find('a').on('click', function (e) { - self.clear(); - }); - } - },0); - - // bind search input - this.$search.find('input') - .focus() - .on('keyup', function (e) { - self.find_existing($(this).val()); - }); - - // bind result - this.$ul.on('click', "li:not(:first) a", function (e) { - self.select_record(this); - }); - }, - - on_focus: function () { - this.$target.attr('contentEditable', 'false'); - this.clear(); - this._super.apply(this, arguments); - }, - - clear: function () { - var self = this; - this.$search.siblings().remove(); - self.$search.find('input').val(""); - setTimeout(function () { - self.$search.find('input').focus(); - },0); - }, - - find_existing: function (name) { - var self = this; - var domain = []; - if (!name || !name.length) { - self.$search.siblings().remove(); - return; - } - if (isNaN(+name)) { - if (this.Model !== "res.partner") { - domain.push(['name', 'ilike', name]); - } else { - domain.push('|', ['name', 'ilike', name], ['email', 'ilike', name]); - } - } else { - domain.push(['id', '=', name]); - } - - ajax.jsonRpc('/web/dataset/call_kw', 'call', { - model: this.Model, - method: 'search_read', - args: [domain, this.Model === "res.partner" ? ['name', 'display_name', 'city', 'country_id'] : ['name', 'display_name']], - kwargs: { - order: 'name DESC', - limit: 5, - context: base.get_context(), - } - }).then(function (result) { - self.$search.siblings().remove(); - self.$search.after(qweb.render("web_editor.many2one.search",{contacts:result})); - }); - }, - - get_contact_rendering: function (options) { - return ajax.jsonRpc('/web/dataset/call_kw', 'call', { - model: 'ir.qweb.field.contact', - method: 'get_record_to_html', - args: [[this.ID]], - kwargs: { - options: options, - context: base.get_context(), - } - }); - }, - - select_record: function (li) { - var self = this; - - this.ID = +$(li).data("id"); - this.$target.attr('data-oe-many2one-id', this.ID).data('oe-many2one-id', this.ID); - - this.buildingBlock.getParent().rte.historyRecordUndo(this.$target); - this.$target.trigger('content_changed'); - - if (self.$target.data('oe-type') === "contact") { - $('[data-oe-contact-options]') - .filter('[data-oe-model="'+self.$target.data('oe-model')+'"]') - .filter('[data-oe-id="'+self.$target.data('oe-id')+'"]') - .filter('[data-oe-field="'+self.$target.data('oe-field')+'"]') - .filter('[data-oe-contact-options!="'+self.$target.data('oe-contact-options')+'"]') - .add(self.$target) - .attr('data-oe-many2one-id', self.ID).data('oe-many2one-id', self.ID) - .each(function () { - var $node = $(this); - self.get_contact_rendering($node.data('oe-contact-options')) - .then(function (html) { - $node.html(html); - }); - }); - } else { - self.$target.html($(li).data("name")); - } - - setTimeout(function () { - self.buildingBlock.make_active(false); - },0); } - }); + }, + /** + * Removes all option-related classes and style on the target element. + * + * @private + */ + _clean: function () { + this.$target.removeClass('o_bg_img_opt_contain o_bg_img_opt_custom o_bg_img_opt_repeat o_bg_img_opt_repeat_x o_bg_img_opt_repeat_y') + .css({ + 'background-size': '', + 'background-position': '', + }); + }, + /** + * Restores the target style before last edition made with the option. + * + * @private + */ + _discardChanges: function () { + this._clean(); + if (this.previous_state) { + this.$target.addClass(this.previous_state[0]).css({ + 'background-size': this.previous_state[1], + 'background-position': this.previous_state[2], + }); + } + }, + /** + * Updates the visual representation of the chosen background position. + * + * @private + */ + _updatePosInformation: function () { + this.modal.$('.o_bg_img_opt_ui_info .o_x').text(this.bg_pos.x); + this.modal.$('.o_bg_img_opt_ui_info .o_y').text(this.bg_pos.y); + this.$focus.css({ + left: this.bg_pos.x, + top: this.bg_pos.y, + }); + }, + /** + * Updates the target element to match the chosen options. + * + * @private + */ + _saveChanges: function () { + this._clean(); - return { - Class: SnippetOption, - registry: registry, - }; + var bg_img_size = this.modal.$('> :not(label):not(.o_hidden)').data('value') || 'cover'; + switch (bg_img_size) { + case 'cover': + this.$target.css('background-position', this.bg_pos.x + ' ' + this.bg_pos.y); + break; + case 'contain': + this.$target.addClass('o_bg_img_opt_contain'); + this.$target.toggleClass('o_bg_img_opt_repeat', this.modal.$('#o_bg_img_opt_contain_repeat').prop('checked')); + break; + case 'custom': + this.$target.addClass('o_bg_img_opt_custom'); + var sizeX = this.modal.$('#o_bg_img_opt_custom_size_x').val(); + var sizeY = this.modal.$('#o_bg_img_opt_custom_size_y').val(); + var posX = this.modal.$('#o_bg_img_opt_custom_pos_x').val(); + var posY = this.modal.$('#o_bg_img_opt_custom_pos_y').val(); + this.$target.addClass(this.modal.$('#o_bg_img_opt_custom_repeat').val()) + .css({ + 'background-size': ((sizeX)? sizeX + '%' : 'auto') + ' ' + ((sizeY)? sizeY + '%' : 'auto'), + 'background-position': ((posX)? posX + '%' : 'auto') + ' ' + ((posY)? posY + '%' : 'auto'), + }); + break; + } + }, +}); + +/** + * Allows to replace a text value with the name of a database record. + * @todo replace this mechanism with real backend m2o field ? + */ +registry.many2one = SnippetOption.extend({ + xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], + /** + * @override + */ + start: function () { + var self = this; + + this.Model = this.$target.data('oe-many2one-model'); + this.ID = +this.$target.data('oe-many2one-id'); + + // create search button and bind search bar + this.$btn = $(qweb.render('web_editor.many2one.button')) + .insertAfter(this.$overlay.find('.oe_options')); + + this.$ul = this.$btn.find('ul'); + this.$search = this.$ul.find('li:first'); + this.$search.find('input').on('mousedown click mouseup keyup keydown', function (e) { + e.stopPropagation(); + }); + + // move menu item + setTimeout(function () { + if (self.$overlay.find('.oe_options').hasClass('hidden')) { + self.$btn.css('height', '0').find('> a').addClass('hidden'); + self.$ul.show().css({ + 'top': '-24px', 'margin': '0', 'padding': '2px 0', 'position': 'relative' + }); + } else { + self.$btn.find('a').on('click', function (e) { + self._clear(); + }); + } + },0); + + // bind search input + this.$search.find('input') + .focus() + .on('keyup', function (e) { + self._findExisting($(this).val()); + }); + + // bind result + this.$ul.on('click', 'li:not(:first) a', function (e) { + self._selectRecord($(e.currentTarget)); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onFocus: function () { + this.$target.attr('contentEditable', 'false'); + this._clear(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Removes the input value and suggestions. + * + * @private + */ + _clear: function () { + var self = this; + this.$search.siblings().remove(); + self.$search.find('input').val(''); + setTimeout(function () { + self.$search.find('input').focus(); + }, 0); + }, + /** + * Find existing record with the given name and suggest them. + * + * @private + * @param {string} name + * @returns {Deferred} + */ + _findExisting: function (name) { + var self = this; + var domain = []; + if (!name || !name.length) { + self.$search.siblings().remove(); + return; + } + if (isNaN(+name)) { + if (this.Model !== 'res.partner') { + domain.push(['name', 'ilike', name]); + } else { + domain.push('|', ['name', 'ilike', name], ['email', 'ilike', name]); + } + } else { + domain.push(['id', '=', name]); + } + + return this._rpc({ + model: this.Model, + method: 'search_read', + args: [domain, this.Model === 'res.partner' ? ['name', 'display_name', 'city', 'country_id'] : ['name', 'display_name']], + kwargs: { + order: 'name DESC', + limit: 5, + context: weContext.get(), + }, + }).then(function (result) { + self.$search.siblings().remove(); + self.$search.after(qweb.render('web_editor.many2one.search',{contacts:result})); + }); + }, + /** + * Selects the given suggestion and displays it the proper way. + * + * @private + * @param {jQuery} $li + */ + _selectRecord: function ($li) { + var self = this; + + this.ID = +$li.data('id'); + this.$target.attr('data-oe-many2one-id', this.ID).data('oe-many2one-id', this.ID); + + this.trigger_up('request_history_undo_record', {$target: this.$target}); + this.$target.trigger('content_changed'); + + if (self.$target.data('oe-type') === 'contact') { + $('[data-oe-contact-options]') + .filter('[data-oe-model="'+self.$target.data('oe-model')+'"]') + .filter('[data-oe-id="'+self.$target.data('oe-id')+'"]') + .filter('[data-oe-field="'+self.$target.data('oe-field')+'"]') + .filter('[data-oe-contact-options!="'+self.$target.data('oe-contact-options')+'"]') + .add(self.$target) + .attr('data-oe-many2one-id', self.ID).data('oe-many2one-id', self.ID) + .each(function () { + var $node = $(this); + var options = $node.data('oe-contact-options'); + self._rpc({ + model: 'ir.qweb.field.contact', + method: 'get_record_to_html', + args: [[self.ID]], + kwargs: { + options: options, + context: weContext.get(), + }, + }).then(function (html) { + $node.html(html); + }); + }); + } else { + self.$target.html($li.data('name')); + } + + _.defer(function () { + self.trigger_up('deactivate_snippet'); + }); + } +}); + +return { + Class: SnippetOption, + registry: registry, +}; }); diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js index c2e2ea54dca..4860931ea6b 100644 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -6,25 +6,21 @@ require('summernote/summernote'); // wait that summernote is loaded var _t = core._t; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* Summernote Lib (neek hack to make accessible: method and object) */ - -var agent = $.summernote.core.agent; +// Summernote Lib (neek hack to make accessible: method and object) +// var agent = $.summernote.core.agent; var dom = $.summernote.core.dom; var range = $.summernote.core.range; var list = $.summernote.core.list; var key = $.summernote.core.key; var eventHandler = $.summernote.eventHandler; -var editor = eventHandler.modules.editor; +// var editor = eventHandler.modules.editor; var renderer = $.summernote.renderer; var options = $.summernote.options; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* Add method to Summernote*/ - +// Add methods to summernote dom.hasContentAfter = function (node) { var next; - if(dom.isEditable(node)) return; + if (dom.isEditable(node)) return; while (node.nextSibling) { next = node.nextSibling; if (next.tagName || dom.isVisibleText(next) || dom.isBR(next)) return next; @@ -33,7 +29,7 @@ dom.hasContentAfter = function (node) { }; dom.hasContentBefore = function (node) { var prev; - if(dom.isEditable(node)) return; + if (dom.isEditable(node)) return; while (node.previousSibling) { prev = node.previousSibling; if (prev.tagName || dom.isVisibleText(prev) || dom.isBR(prev)) return prev; @@ -97,12 +93,12 @@ dom.isEqual = function (prev, cur) { } var att, att2; loop_prev: - for(var a in prev.attributes) { + for (var a in prev.attributes) { att = prev.attributes[a]; - for(var b in cur.attributes) { + for (var b in cur.attributes) { att2 = cur.attributes[b]; if (att.name === att2.name) { - if (strip(att.value) != strip(att2.value)) return false; + if (strip(att.value) !== strip(att2.value)) return false; continue loop_prev; } } @@ -110,9 +106,6 @@ dom.isEqual = function (prev, cur) { } return true; }; -/** - * Checks that the node only has a @style, not e.g. @class or whatever - */ dom.hasOnlyStyle = function (node) { for (var i = 0; i < node.attributes.length; i++) { var attr = node.attributes[i]; @@ -199,7 +192,7 @@ dom.merge = function (node, begin, so, end, eo, mergeFilter, all) { if (!begin) { begin = node; - while(begin.firstChild) {begin = begin.firstChild;} + while (begin.firstChild) {begin = begin.firstChild;} so = 0; } else if (begin.tagName && begin.childNodes[so]) { begin = begin.childNodes[so]; @@ -207,10 +200,8 @@ dom.merge = function (node, begin, so, end, eo, mergeFilter, all) { } if (!end) { end = node; - while(end.lastChild) {end = end.lastChild;} + while (end.lastChild) {end = end.lastChild;} eo = end.textContent.length-1; - } else if (end.tagName) { - } else if (end.tagName && end.childNodes[so]) { end = end.childNodes[so]; so = 0; @@ -225,7 +216,7 @@ dom.merge = function (node, begin, so, end, eo, mergeFilter, all) { eo = 0; } - function __merge (node) { + function __merge(node) { var merged = false; var prev; for (var k=0; k crappy dom return !dom.isText(node) && !dom.isBR(dom.firstChild(node)) && dom.isVoid(dom.firstChild(node)); }; - dom.isContentEditable = function (node) { return $(node).closest('[contenteditable]').is('[contenteditable="true"]'); }; - -dom.isPre = function (node) { - return node.tagName === "PRE"; -}; dom.isFont = function (node) { var nodeName = node && node.nodeName.toUpperCase(); return node && (nodeName === "FONT" || @@ -678,19 +662,10 @@ dom.isFont = function (node) { dom.isVisibleText = function (textNode) { return !!textNode.textContent.match(/\S|\u00A0/); }; - -/* avoid thinking HTML comment are visible */ var old_isVisiblePoint = dom.isVisiblePoint; dom.isVisiblePoint = function (point) { return point.node.nodeType !== 8 && old_isVisiblePoint.apply(this, arguments); }; - -/** - * order the style of the node to compare 2 nodes and remove attribute if empty - * - * @param {Node} node - * @return {String} className - */ dom.orderStyle = function (node) { var style = node.getAttribute('style'); if (!style) return null; @@ -705,12 +680,6 @@ dom.orderStyle = function (node) { node.setAttribute('style', style); return style; }; -/** - * order the class of the node to compare 2 nodes and remove attribute if empty - * - * @param {Node} node - * @return {String} className - */ dom.orderClass = function (node) { var className = node.getAttribute && node.getAttribute('class'); if (!className) return null; @@ -725,21 +694,9 @@ dom.orderClass = function (node) { node.setAttribute('class', className); return className; }; -/** - * return the current node (if text node, return parentNode) - * - * @param {Node} node - * @return {Node} node - */ dom.node = function (node) { return dom.isText(node) ? node.parentNode : node; }; -/** - * move all childNode to an other node - * - * @param {Node} from - * @param {Node} to - */ dom.moveContent = function (from, to) { if (from === to) { return; @@ -749,18 +706,16 @@ dom.moveContent = function (from, to) { dom.insertAfter(from.lastChild, from); } } else { - while (from.firstChild && from.firstChild != to) { + while (from.firstChild && from.firstChild !== to) { to.appendChild(from.firstChild); } } }; - dom.getComputedStyle = function (node) { - return node.nodeType == Node.COMMENT_NODE ? {} : window.getComputedStyle(node); + return node.nodeType === Node.COMMENT_NODE ? {} : window.getComputedStyle(node); }; - -////////////////////////////////////////////////////////////////////////////////////////////////////////// +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: range.WrappedRange.prototype.reRange = function (keep_end, isNotBreakable) { var sc = this.sc; @@ -894,7 +849,7 @@ range.WrappedRange.prototype.isContentEditable = function () { return dom.isContentEditable(this.sc) && (this.sc === this.ec || dom.isContentEditable(this.ec)); }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: renderer.tplButtonInfo.fontsize = function (lang, options) { var items = options.fontSizes.reduce(function (memo, v) { @@ -910,8 +865,7 @@ renderer.tplButtonInfo.fontsize = function (lang, options) { }); }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* add some text commands */ +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: key.nameFromCode[46] = 'DELETE'; key.nameFromCode[27] = 'ESCAPE'; @@ -960,7 +914,7 @@ $.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) { var $editable = layoutInfo.editable(); $editable.data('NoteHistory').recordUndo($editable, 'tab'); var r = range.create(); - var outdent = outdent || false; + outdent = outdent || false; event.preventDefault(); if (r && (dom.ancestor(r.sc, dom.isCell) || dom.ancestor(r.ec, dom.isCell))) { @@ -986,11 +940,12 @@ $.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) { $.summernote.pluginEvents.indent(event, editor, layoutInfo); } } else { + var next; if (!outdent) { - if(dom.isText(r.sc)) { - var next = r.sc.splitText(r.so); + if (dom.isText(r.sc)) { + next = r.sc.splitText(r.so); } else { - var next = document.createTextNode('') + next = document.createTextNode(''); $(r.sc.childNodes[r.so]).before(next); } editor.typing.insertTab($editable, r, options.tabsize); @@ -1000,7 +955,7 @@ $.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) { } else { r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true); r = range.create(r.sc, r.so, r.ec, r.eo); - var next = r.sc.splitText(r.so); + next = r.sc.splitText(r.so); r.sc.textContent = r.sc.textContent.replace(/(\u00A0)+$/g, ''); next.textContent = next.textContent.replace(/^(\u00A0)+/g, ''); range.create(r.sc, r.sc.textContent.length, r.sc, r.sc.textContent.length).select(); @@ -1062,27 +1017,30 @@ $.summernote.pluginEvents.enter = function (event, editor, layoutInfo) { var br = $("
    ")[0]; + var node; + var $node; + var $clone; var contentBefore = r.sc.textContent.slice(0,r.so).match(/\S|\u00A0/); if (!contentBefore && dom.isText(r.sc)) { - var node = r.sc.previousSibling; + node = r.sc.previousSibling; while (!contentBefore && node && dom.isText(node)) { contentBefore = dom.isVisibleText(node); node = node.previousSibling; } } - var node = dom.node(r.sc); + node = dom.node(r.sc); var exist = r.sc.childNodes[r.so] || r.sc; exist = dom.isVisibleText(exist) || dom.isBR(exist) ? exist : dom.hasContentAfter(exist) || (dom.hasContentBefore(exist) || exist); // table: add a tr var td = dom.ancestor(node, dom.isCell); if (td && !dom.nextElementSibling(node) && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode) && (!dom.isText(r.sc) || !r.sc.textContent.slice(r.so).match(/\S|\u00A0/))) { - var $node = $(td.parentNode); - var $clone = $node.clone(); + $node = $(td.parentNode); + $clone = $node.clone(); $clone.children().html(dom.blank); $node.after($clone); - var node = dom.firstElementChild($clone[0]) || $clone[0]; + node = dom.firstElementChild($clone[0]) || $clone[0]; range.create(node, 0, node, 0).select(); dom.scrollIntoViewIfNeeded(br); event.preventDefault(); @@ -1125,8 +1083,8 @@ $.summernote.pluginEvents.enter = function (event, editor, layoutInfo) { if (dom.isBR(last)) { last = last.parentNode; } - var $node = $(last); - var $clone = $node.clone().text(""); + $node = $(last); + $clone = $node.clone().text(""); $node.after($clone); node = dom.node(dom.firstElementChild($clone[0]) || $clone[0]); $(node).html(br); @@ -1197,22 +1155,7 @@ $.summernote.pluginEvents.visible = function (event, editor, layoutInfo) { return true; }; -function summernote_keydown_clean (field) { - setTimeout(function () { - var r = range.create(); - if (!r) return; - var node = r[field]; - while (dom.isText(node)) {node = node.parentNode;} - node = node.parentNode; - var data = dom.merge(node, r.sc, r.so, r.ec, r.eo, null, true); - data = dom.removeSpace(node.parentNode, data.sc, data.so, data.sc, data.so); - - range.create(data.sc, data.so, data.sc, data.so).select(); - },0); -} - function remove_table_content(r) { - var ancestor = r.commonAncestor(); var nodes = dom.listBetween(r.sc, r.ec); if (dom.isText(r.sc)) { r.sc.textContent = r.sc.textContent.slice(0, r.so); @@ -1226,12 +1169,12 @@ function remove_table_content(r) { continue; } else if (dom.isCell(node)) { $(node).html("
    "); - } else if(node.parentNode) { + } else if (node.parentNode) { do { var parent = node.parentNode; parent.removeChild(node); node = parent; - } while(!dom.isVisibleText(node) && !dom.firstElementChild(node) && + } while (!dom.isVisibleText(node) && !dom.firstElementChild(node) && !dom.isCell(node) && node.parentNode && !$(node.parentNode).hasClass('o_editable')); } @@ -1282,12 +1225,12 @@ $.summernote.pluginEvents.delete = function (event, editor, layoutInfo) { var content = target.textContent.replace(/[ \t\r\n]+$/, ''); var temp; var temp2; + var next; // media if (dom.isImg(node) || (!contentAfter && dom.isImg(dom.hasContentAfter(node)))) { var parent; var index; - var r; if (!dom.isImg(node)) { node = dom.hasContentAfter(node); } @@ -1295,7 +1238,7 @@ $.summernote.pluginEvents.delete = function (event, editor, layoutInfo) { parent = node.parentNode; index = dom.position(node); if (index>0) { - var next = node.previousSibling; + next = node.previousSibling; r = range.create(next, next.textContent.length); } else { r = range.create(parent, 0); @@ -1315,7 +1258,7 @@ $.summernote.pluginEvents.delete = function (event, editor, layoutInfo) { return false; } var before = false; - var next = dom.hasContentAfter(dom.ancestorHaveNextSibling(node)); + next = dom.hasContentAfter(dom.ancestorHaveNextSibling(node)); if (!dom.isContentEditable(next)) { before = true; next = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node)); @@ -1343,9 +1286,9 @@ $.summernote.pluginEvents.delete = function (event, editor, layoutInfo) { !dom.isNotBreakable(temp) && !dom.isNotBreakable(temp2)) { dom.autoMerge(target, false); - var next = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(target))); + next = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(target))); if (dom.isBR(next)) { - if(dom.position(next) == 0) { + if (dom.position(next) === 0) { range.create(next.parentNode, 0).select(); } else { @@ -1426,6 +1369,7 @@ $.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { var content = target.textContent.replace(/[ \t\r\n]+$/, ''); var temp; var temp2; + var prev; // delete media if (dom.isImg(node) || (!contentBefore && dom.isImg(dom.hasContentBefore(node)))) { @@ -1450,7 +1394,7 @@ $.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { node = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(tr)) || $editable.get(0)); } $(tr).empty(); - if(!$(tr).closest('table').has('td, th').length) { + if (!$(tr).closest('table').has('td, th').length) { $(tr).closest('table').remove(); } $(tr).remove(); @@ -1472,7 +1416,7 @@ $.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { return false; } var before = true; - var prev = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node)); + prev = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node)); if (!dom.isContentEditable(prev)) { before = false; prev = dom.hasContentAfter(dom.ancestorHaveNextSibling(node)); @@ -1499,7 +1443,7 @@ $.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li !dom.isNotBreakable(temp) && !dom.isNotBreakable(temp2)) { - var prev = dom.firstChild(target); + prev = dom.firstChild(target); dom.autoMerge(target, true); range.create(prev, 0).select(); } @@ -1521,7 +1465,7 @@ $.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { } } - var r = range.create(); + r = range.create(); if (r) { $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler dom.scrollIntoViewIfNeeded(r.sc.parentNode.previousElementSibling || r.sc); @@ -1531,9 +1475,6 @@ $.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { return false; }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* add list command (create a uggly dom for chrome) */ - function isFormatNode(node) { return node.tagName && options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1; } @@ -1603,13 +1544,13 @@ $.summernote.pluginEvents.insertUnorderedList = function (event, editor, layoutI return; } - var parent = p0.parentNode; - var ul = document.createElement(sorted ? "ol" : "ul"); + parent = p0.parentNode; + ul = document.createElement(sorted ? "ol" : "ul"); parent.insertBefore(ul, p0); var childNodes = parent.childNodes; var brs = []; var begin = false; - for (var i=0; i0 && (font = dom.ancestor(nodes[i-1], dom.isFont))) { className2 = font.getAttribute('class'); style2 = font.getAttribute('style'); - if (node !== font && className == className2 && style == style2) { + if (node !== font && className === className2 && style === style2) { remove(node, font); nodes.splice(i,1); i--; @@ -2169,7 +2111,6 @@ $.summernote.pluginEvents.fontSize = function (event, editor, layoutInfo, value) editor.afterCommand($editable); }; $.summernote.pluginEvents.color = function (event, editor, layoutInfo, sObjColor) { - var $editable = layoutInfo.editable(); var oColor = JSON.parse(sObjColor); var foreColor = oColor.foreColor, backColor = oColor.backColor; @@ -2200,8 +2141,6 @@ $.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backC editor.afterCommand($editable); }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// - options.onCreateLink = function (sLinkUrl) { if (sLinkUrl.indexOf('mailto:') === 0) { // pass @@ -2213,16 +2152,13 @@ options.onCreateLink = function (sLinkUrl) { return sLinkUrl; }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// -/* table */ - -function summernote_table_scroll (event) { +function summernote_table_scroll(event) { var r = range.create(); if (r && r.isOnCell()) { $('.o_table_handler').remove(); } } -function summernote_table_update (oStyle) { +function summernote_table_update(oStyle) { var r = range.create(); if (!oStyle.range || !r || !r.isOnCell() || !r.isOnCellFirst()) { $('.o_table_handler').remove(); @@ -2334,15 +2270,15 @@ function summernote_table_update (oStyle) { var fn_popover_update = eventHandler.modules.popover.update; eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) { fn_popover_update.call(this, $popover, oStyle, isAirMode); - if(!!(isAirMode ? $popover : $popover.parent()).find('.note-table').length) { + if ((isAirMode ? $popover : $popover.parent()).find('.note-table').length) { summernote_table_update(oStyle); } }; // override summernote clipboard functionality -eventHandler.modules.clipboard.attach = function(layoutInfo) { +eventHandler.modules.clipboard.attach = function (layoutInfo) { var $editable = layoutInfo.editable(); - $editable.on('paste', function(e) { + $editable.on('paste', function (e) { var clipboardData = ((e.originalEvent || e).clipboardData || window.clipboardData); // Change nothing if pasting html (copy from text editor / web / ...) or // if clipboardData is not available (IE / ...) @@ -2359,8 +2295,6 @@ eventHandler.modules.clipboard.attach = function(layoutInfo) { }); }; -////////////////////////////////////////////////////////////////////////////////////////////////////////// - var fn_attach = eventHandler.attach; eventHandler.attach = function (oLayoutInfo, options) { var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable'); @@ -2379,5 +2313,4 @@ options.icons.image.image = "file-image-o"; $.summernote.lang['en-US'].image.image = "File / Image"; return $.summernote; - }); diff --git a/addons/web_editor/static/src/js/editor/transcoder.js b/addons/web_editor/static/src/js/editor/transcoder.js index 6b42e028b3a..11d23bf603b 100644 --- a/addons/web_editor/static/src/js/editor/transcoder.js +++ b/addons/web_editor/static/src/js/editor/transcoder.js @@ -4,39 +4,49 @@ odoo.define('web_editor.transcoder', function (require) { var widget = require('web_editor.widget'); var rulesCache = []; -var getMatchedCSSRules = function (a) { + +/** + * Returns the css rules which applies on an element, tweaked so that they are + * browser/mail client ok. + * + * @param {DOMElement} a + * @returns {Object} css property name -> css property value + */ +function getMatchedCSSRules(a) { + var i, r, k; if (!rulesCache.length) { var sheets = document.styleSheets; - for(var i = sheets.length-1; i >= 0 ; i--) { + for (i = sheets.length-1 ; i >= 0 ; i--) { var rules; if (sheets[i].rules) { rules = sheets[i].rules; } else { - //try...catch because Firefox not able to enumerate document.styleSheets[].cssRules[] for cross-domain stylesheets. + // try...catch because Firefox not able to enumerate + // document.styleSheets[].cssRules[] for cross-domain sheets. try { rules = sheets[i].cssRules; - } catch(e) { + } catch (e) { console.warn("Can't read the css rules of: " + sheets[i].href, e); continue; } rules = sheets[i].cssRules; } if (rules) { - for(var r = rules.length-1; r >= 0; r--) { + for (r = rules.length-1; r >= 0; r--) { var selectorText = rules[r].selectorText; if (selectorText && rules[r].cssText && - selectorText !== "*" && - selectorText.indexOf(":hover") === -1 && - selectorText.indexOf(":before") === -1 && - selectorText.indexOf(":after") === -1 && - selectorText.indexOf(":active") === -1 && - selectorText.indexOf(":link") === -1 && - selectorText.indexOf("::") === -1 && - selectorText.indexOf("\"") === -1 && + selectorText !== '*' && + selectorText.indexOf(':hover') === -1 && + selectorText.indexOf(':before') === -1 && + selectorText.indexOf(':after') === -1 && + selectorText.indexOf(':active') === -1 && + selectorText.indexOf(':link') === -1 && + selectorText.indexOf('::') === -1 && + selectorText.indexOf('"') === -1 && selectorText.indexOf("'") === -1) { var st = selectorText.split(/\s*,\s*/); - for (var k=0; k property: [top/right/bottom/left value]; @@ -132,103 +144,125 @@ var getMatchedCSSRules = function (a) { } return style; -}; +} -// convert font awsome into image -var font_to_img = function ($editable) { - $editable.find(".fa").each(function () { +/** + * Converts font icons to images. + * + * @param {jQuery} $editable - the element in which the font icons have to be + * converted to images + */ +function fontToImg($editable) { + $editable.find('.fa').each(function () { var $font = $(this); var icon, content; _.find(widget.fontIcons, function (font) { return _.find(widget.getCssSelectors(font.parser), function (css) { if ($font.is(css[0].replace(/::?before/g, ''))) { - icon = css[2].split("-").shift(); + icon = css[2].split('-').shift(); content = css[1].match(/content:\s*['"]?(.)['"]?/)[1]; return true; } }); }); if (content) { - var color = $font.css("color").replace(/\s/g, ''); - $font.replaceWith($("", { - "src": _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, $font.height())), - "data-class": $font.attr("class"), - "data-style": $font.attr("style"), - "class": $font.attr("class").replace(new RegExp("(^|\\s+)" + icon + "(-[^\\s]+)?", "gi"), ''), // remove inline font-awsome style - "style": $font.attr("style") - }).css({height: "auto", width: "auto"})); + var color = $font.css('color').replace(/\s/g, ''); + $font.replaceWith($('', { + src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, $font.height())), + 'data-class': $font.attr('class'), + 'data-style': $font.attr('style'), + class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style + style: $font.attr('style'), + }).css({height: 'auto', width: 'auto'})); } else { $font.remove(); } }); -}; -// convert image into font awsome -var img_to_font = function ($editable) { - $editable.find("img[src*='/web_editor/font_to_img/']").each(function () { +} + +/** + * Converts images which were the result of a font icon convertion to a font + * icon again. + * + * @param {jQuery} $editable - the element in which the images will be converted + * back to font icons + */ +function imgToFont($editable) { + $editable.find('img[src*="/web_editor/font_to_img/"]').each(function () { var $img = $(this); - $img.replaceWith($("", { - "class": $img.data("class"), - "style": $img.data("style") + $img.replaceWith($('', { + class: $img.data('class'), + style: $img.data('style') })); }); -}; +} -// convert class into inline style to send by mail -var class_to_style = function ($editable) { +/** + * Converts css style to inline style (leave the classes on elements but forces + * the style they give as inline style). + * + * @param {jQuery} $editable + */ +function classToStyle($editable) { if (!rulesCache.length) { getMatchedCSSRules($editable[0]); } $editable.find('*').each(function () { var $target = $(this); var css = getMatchedCSSRules(this); - var style = $target.attr("style") || ""; + var style = $target.attr('style') || ''; _.each(css, function (v,k) { if (!(new RegExp('(^|;)\s*' + k).test(style))) { - style = k+":"+v+";"+style; + style = k+':'+v+';'+style; } }); if (_.isEmpty(style)) { - $target.removeAttr("style"); + $target.removeAttr('style'); } else { - $target.attr("style", style); + $target.attr('style', style); } }); -}; -// convert style into inline class from mail -var style_to_class = function ($editable) { +} + +/** + * Removes the inline style which is not necessary (because, for example, a + * class on an element will induce the same style). + * + * @param {jQuery} $editable + */ +function styleToClass($editable) { getMatchedCSSRules($editable[0]); - var $c = $('').appendTo("body"); + var $c = $('').appendTo(document.body); $editable.find('*').each(function () { var $target = $(this); var css = getMatchedCSSRules(this); - var style = ""; + var style = ''; _.each(css, function (v,k) { if (!(new RegExp('(^|;)\s*' + k).test(style))) { - style = k+":"+v+";"+style; + style = k+':'+v+';'+style; } }); - css = ($c.attr("style", style).attr("style") || "").split(/\s*;\s*/); - style = $target.attr("style") || ""; + css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); + style = $target.attr('style') || ''; _.each(css, function (v) { style = style.replace(v, ''); }); style = style.replace(/;+(\s;)*/g, ';').replace(/^;/g, ''); if (style !== '') { - $target.attr("style", style); + $target.attr('style', style); } else { - $target.removeAttr("style"); + $target.removeAttr('style'); } }); $c.remove(); -}; +} return { - 'font_to_img': font_to_img, - 'img_to_font': img_to_font, - 'class_to_style': class_to_style, - 'style_to_class': style_to_class, + fontToImg: fontToImg, + imgToFont: imgToFont, + classToStyle: classToStyle, + styleToClass: styleToClass, }; - }); diff --git a/addons/web_editor/static/src/js/editor/translator.js b/addons/web_editor/static/src/js/editor/translator.js index e82ebbb5b81..c28a578fac0 100644 --- a/addons/web_editor/static/src/js/editor/translator.js +++ b/addons/web_editor/static/src/js/editor/translator.js @@ -2,70 +2,65 @@ odoo.define('web_editor.translate', function (require) { 'use strict'; var core = require('web.core'); -var ajax = require('web.ajax'); +var Dialog = require('web.Dialog'); +var localStorage = require('web.local_storage'); var Widget = require('web.Widget'); -var base = require('web_editor.base'); +var weContext = require('web_editor.context'); var rte = require('web_editor.rte'); -var editor_widget = require('web_editor.widget'); +var weWidgets = require('web_editor.widget'); var _t = core._t; -var translatable = !!$('html').data('translatable'); -var edit_translations = !!$('html').data('edit_translations'); +var localStorageNoDialogKey = 'website_translator_nodialog'; -var RTE_Translate = rte.Class.extend({ - saveElement: function ($el, context) { - // remove multi edition - var key = $el.data('oe-translation-id') ? 'translation:'+$el.data('oe-translation-id') : - ($el.data('oe-model') ? $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression') : false); - if (!key || this.__saved[key]) return true; - this.__saved[key] = true; +var RTETranslatorWidget = rte.Class.extend({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * If the element holds a translation, saves it. Otherwise, fallback to the + * standard saving but with the lang kept. + * + * @override + */ + _saveElement: function ($el, context, withLang) { if ($el.data('oe-translation-id')) { - var translation_content = this.getEscapedElement($el).html(); - - return ajax.jsonRpc('/web/dataset/call', 'call', { + return this._rpc({ model: 'ir.translation', method: 'save_html', args: [ [+$el.data('oe-translation-id')], - translation_content, - context || base.get_context() - ], - }); - } else { - var markup = this.getEscapedElement($el).prop('outerHTML'); - - return ajax.jsonRpc('/web/dataset/call', 'call', { - model: 'ir.ui.view', - method: 'save', - args: [ - $el.data('oe-id'), - markup, - $el.data('oe-xpath') || null, - context || base.get_context() + this._getEscapedElement($el).html(), + context || weContext.get() ], }); } + return this._super($el, context, withLang === undefined ? true : withLang); }, }); -var Translate_Modal = editor_widget.Dialog.extend({ - init: function (p, options, parent, node) { - this._super(p, _.extend({}, { +var AttributeTranslateDialog = weWidgets.Dialog.extend({ + /** + * @constructor + */ + init: function (parent, options, node) { + this._super(parent, _.extend({ title: _t("Translate Attribute"), buttons: [ - {text: _t("Close"), classes: "btn-primary o_save_button", close: true, click: this.save} - ] + {text: _t("Close"), classes: 'btn-primary o_save_button', click: this.save} + ], }, options || {})); - this.parent = parent; this.$target = $(node); this.translation = $(node).data('translation'); }, + /** + * @override + */ start: function () { var self = this; - var def = this._super.apply(this, arguments); - var $group = $("
    ", {"class": "form-group"}).appendTo(this.$el); + var $group = $('
    ', {class: 'form-group'}).appendTo(this.$el); _.each(this.translation, function (node, attr) { var $node = $(node); var $label = $('').text(attr); @@ -74,42 +69,68 @@ var Translate_Modal = editor_widget.Dialog.extend({ var value = $input.val(); $node.html(value).trigger('change', node); $node.data('$node').attr($node.data('attribute'), value).trigger('translate'); - self.parent.rte_changed(node); + self.trigger_up('rte_change', {target: node}); }); $group.append($label).append($input); }); - return def; + return this._super.apply(this, arguments); } }); -var Translate = Widget.extend({ - events: { - 'click [data-action="save"]': 'save_and_reload', - 'click [data-action="cancel"]': 'cancel', +var TranslatorInfoDialog = Dialog.extend({ + template: 'web_editor.TranslatorInfoDialog', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web_editor/static/src/xml/translator.xml'] + ), + + /** + * @constructor + */ + init: function (parent, options) { + this._super(parent, _.extend({ + title: _t("Translation Info"), + buttons: [ + {text: _t("Ok, never show me this again"), classes: 'btn-primary', close: true, click: this._onStrongOk.bind(this)}, + {text: _t("Ok"), close: true} + ], + }, options || {})); }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "strong" ok is clicked -> adapt localstorage to make sure + * the dialog is never displayed again. + * + * @private + */ + _onStrongOk: function () { + localStorage.setItem(localStorageNoDialogKey, true); + }, +}); + +var TranslatorMenuBar = Widget.extend({ template: 'web_editor.editorbar', + xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], + events: { + 'click [data-action="save"]': '_onSaveClick', + 'click [data-action="cancel"]': '_onCancelClick', + }, + custom_events: { + 'rte_change': '_onRTEChange', + }, + + /** + * @constructor + */ init: function (parent, $target, lang) { - this.parent = parent; - this.lang = lang || base.get_context().lang; - this.setTarget($target); this._super.apply(this, arguments); - this.rte = new RTE_Translate(this, this.config); - this.rte.on('change', this, this.rte_changed); - }, - start: function () { - this._super.apply(this, arguments); - this.$('#web_editor-toolbars').remove(); - return this.edit(); - }, - setTarget: function ($target) { var $edit = $target.find('[data-oe-translation-id], [data-oe-model][data-oe-id][data-oe-field]'); $edit.filter(':has([data-oe-translation-id], [data-oe-model][data-oe-id][data-oe-field])').attr('data-oe-readonly', true); - this.$target = $edit.not('[data-oe-readonly]'); - - // attributes - var attrs = ['placeholder', 'title', 'alt']; _.each(attrs, function (attr) { $target.find('['+attr+'*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img').each(function () { @@ -135,39 +156,99 @@ var Translate = Widget.extend({ }); this.$target_attr = $target.find('.o_translatable_attribute'); this.$target_attribute = $('.o_editable_translatable_attribute'); + + this.lang = lang || weContext.get().lang; + + this.rte = new RTETranslatorWidget(this, this._getRTEConfig); }, - find: function (selector) { - return selector ? this.$target.find(selector).addBack().filter(selector) : this.$target; - }, - edit: function () { + /** + * @override + */ + start: function () { + this.$('#web_editor-toolbars').remove(); + var flag = false; window.onbeforeunload = function (event) { if ($('.o_editable.o_dirty').length && !flag) { flag = true; - setTimeout(function () {flag=false;},0); + setTimeout(function () { + flag = false; + }, 0); return _t('This document is not saved!'); } }; - this.$target.addClass("o_editable"); + this.$target.addClass('o_editable'); this.rte.start(); this.translations = []; - this.markTranslatableNodes(); - this.onTranslateReady(); + this._markTranslatableNodes(); + this.$el.show(); // TODO seems useless + this.trigger('edit'); + + if (!localStorage.getItem(localStorageNoDialogKey)) { + new TranslatorInfoDialog(this).open(); + } + + return this._super.apply(this, arguments); }, - onTranslateReady: function () { - this.$el.show(); - this.trigger("edit"); + /** + * @override + */ + destroy: function () { + this._cancel(); + this._super.apply(this, arguments); }, - rte_changed: function (node) { - var $node = $(node); - $node.find("p").each(function () { // remove

    element which might have been inserted because of copy-paste - var $p = $(this); - $p.after($p.html()).remove(); + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Leaves translation mode by hiding the translator bar. + * + * @todo should be destroyed? + * @private + */ + _cancel: function () { + var self = this; + this.rte.cancel(); + this.$target.each(function () { + $(this).html(self._getTranlationObject(this).value); }); - var trans = this.getTranlationObject($node[0]); - $node.toggleClass('o_dirty', trans.value !== $node.html().replace(/[ \t\n\r]+/, ' ')); + this._unmarkTranslatableNode(); + this.trigger('cancel'); + this.$el.hide(); + window.onbeforeunload = null; }, - getTranlationObject: function (node) { + /** + * Returns the RTE summernote configuration for translation mode. + * + * @private + * @param {jQuery} $editable + */ + _getRTEConfig: function ($editable) { + return { + airMode : true, + focus: false, + airPopover: $editable.data('oe-model') ? [ + ['history', ['undo', 'redo']], + ] : [ + ['font', ['bold', 'italic', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['color', ['color']], + ['history', ['undo', 'redo']], + ], + styleWithSpan: false, + inlinemedia : ['p'], + lang: 'odoo', + onChange: function (html, $editable) { + $editable.trigger('content_changed'); + }, + }; + }, + /** + * @private + */ + _getTranlationObject: function (node) { var $node = $(node); var id = +$node.data('oe-translation-id'); if (!id) { @@ -181,33 +262,23 @@ var Translate = Widget.extend({ } return trans; }, - __unbind_click: function (event) { - if (event.ctrlKey || !$(event.target).is(':o_editable')) { - return; - } - event.preventDefault(); - event.stopPropagation(); - }, - __translate_attribute: function (event) { - if (event.ctrlKey) { - return; - } - event.preventDefault(); - event.stopPropagation(); - if (event.type !== 'mousedown') { - return; - } - - new Translate_Modal(null, {}, event.data, event.target).open(); - }, - markTranslatableNodes: function (node) { + /** + * @private + */ + _markTranslatableNodes: function () { var self = this; this.$target.add(this.$target_attribute).each(function () { var $node = $(this); - var trans = self.getTranlationObject(this); + var trans = self._getTranlationObject(this); trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' '); }); - this.$target.parent().prependEvent('click.translator', this, this.__unbind_click); + this.$target.parent().prependEvent('click.translator', function (ev) { + if (ev.ctrlKey || !$(ev.target).is(':o_editable')) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + }); // attributes @@ -215,105 +286,86 @@ var Translate = Widget.extend({ var $node = $(this); var translation = $node.data('translation'); _.each(translation, function (node, attr) { - var trans = self.getTranlationObject(node); + var trans = self._getTranlationObject(node); trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' '); $node.attr('data-oe-translation-state', (trans.state || 'to_translate')); }); }); - this.$target_attr.prependEvent('mousedown.translator click.translator mouseup.translator', this, this.__translate_attribute); + this.$target_attr.prependEvent('mousedown.translator click.translator mouseup.translator', function (ev) { + if (ev.ctrlKey) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + if (ev.type !== 'mousedown') { + return; + } - console.info('Click on CTRL when you click in an translatable area to have the default behavior'); + new AttributeTranslateDialog(self, {}, ev.target).open(); + }); }, - unarkTranslatableNode: function () { + /** + * Saves the translation and reloads the page to leave edit mode. + * + * @private + * @returns {Deferred} (never resolved as the page is reloading anyway) + */ + _save: function () { + return this.rte.save(weContext.get({lang: this.lang})).then(function () { + window.location.href = window.location.href.replace(/&?edit_translations(=[^&]*)?/g, ''); + return $.Deferred(); + }); + }, + /** + * @private + */ + _unmarkTranslatableNode: function () { this.$target.removeClass('o_editable').removeAttr('contentEditable'); this.$target.parent().off('.translator'); this.$target_attr.off('.translator'); }, - save_and_reload: function () { - return this.save().then(function () { - window.location.href = window.location.href.replace(/&?edit_translations(=[^&]*)?/g, ''); - }); - }, - save: function () { - var context = base.get_context(); - context.lang = this.lang; - return this.rte.save(context); - }, - cancel: function () { - var self = this; - this.rte.cancel(); - this.$target.each(function () { - $(this).html(self.getTranlationObject(this).value); - }); - this.unarkTranslatableNode(); - this.trigger("cancel"); - this.$el.hide(); - window.onbeforeunload = null; - }, - destroy: function () { - this.cancel(); - this.$el.remove(); - this._super.apply(this, arguments); - }, - config: function ($editable) { - if ($editable.data('oe-model')) { - return { - 'airMode' : true, - 'focus': false, - 'airPopover': [ - ['history', ['undo', 'redo']], - ], - 'styleWithSpan': false, - 'inlinemedia' : ['p'], - 'lang': "odoo", - 'onChange': function (html, $editable) { - $editable.trigger("content_changed"); - } - }; - } - return { - 'airMode' : true, - 'focus': false, - 'airPopover': [ - ['font', ['bold', 'italic', 'underline', 'clear']], - ['fontsize', ['fontsize']], - ['color', ['color']], - ['history', ['undo', 'redo']], - ], - 'styleWithSpan': false, - 'inlinemedia' : ['p'], - 'lang': "odoo", - 'onChange': function (html, $editable) { - $editable.trigger("content_changed"); - } - }; - } + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "cancel" button is clicked -> undo changes and leaves + * edition. + * + * @private + */ + _onCancelClick: function () { + this._cancel(); + }, + /** + * Called when text is edited -> make sure text is not messed up and mark + * the element as dirty. + * + * @private + * @param {OdooEvent} ev + */ + _onRTEChange: function (ev) { + var $node = $(ev.data.target); + $node.find('p').each(function () { // remove

    element which might have been inserted because of copy-paste + var $p = $(this); + $p.after($p.html()).remove(); + }); + var trans = this._getTranlationObject($node[0]); + $node.toggleClass('o_dirty', trans.value !== $node.html().replace(/[ \t\n\r]+/, ' ')); + }, + /** + * Called when the "save" button is clicked -> saves the translations. + * + * @private + */ + _onSaveClick: function () { + this._save(); + }, }); - -if (edit_translations) { - base.ready().then(function () { - data.instance = new Translate(this, $('#wrapwrap')); - data.instance.prependTo(document.body); - - $('a[href*=edit_translations]').each(function () { - this.href = this.href.replace(/[$?]edit_translations[^&?]+/, ''); - }); - $('form[action*=edit_translations]').each(function () { - this.action = this.action.replace(/[$?]edit_translations[^&?]+/, ''); - }); - - $('title').html($('title').html().replace(/<span data-oe-model.+?>(.+?)<\/span>/, '\$1')); - }); -} - -var data = { - 'translatable': translatable, - 'edit_translations': edit_translations, - 'Class': Translate, +return { + Class: TranslatorMenuBar, }; -return data; - }); diff --git a/addons/web_editor/static/src/js/iframe.js b/addons/web_editor/static/src/js/iframe.js index 8b4fd385e81..c2727daf52a 100644 --- a/addons/web_editor/static/src/js/iframe.js +++ b/addons/web_editor/static/src/js/iframe.js @@ -1,29 +1,29 @@ odoo.define('web_editor.iframe', function (require) { 'use strict'; -var ajax = require("web.ajax"); -var core = require("web.core"); +var core = require('web.core'); var editor = require('web_editor.editor'); var translator = require('web_editor.translate'); var rte = require('web_editor.rte'); -var snippet_editor = require('web_editor.snippet.editor'); -var callback = window ? window["callback"] : undefined; -window.top.odoo[callback+"_updown"] = function (value, fields_values, field_name) { - var $editable = $("#editable_area"); - if(value !== $editable.prop("innerHTML")) { - if ($('body').hasClass('editor_enable')) { - if (value !== fields_values[field_name]) { - rte.history.recordUndo($editable); - } - snippet_editor.instance.make_active(false); +var callback = window ? window['callback'] : undefined; +window.top.odoo[callback + '_updown'] = function (value, fields_values, field_name) { + var $editable = $('#editable_area'); + if (value === $editable.prop('innerHTML')) { + return; + } + + if ($('body').hasClass('editor_enable')) { + if (value !== fields_values[field_name]) { + rte.history.recordUndo($editable); } + core.bus.trigger('deactivate_snippet'); + } - $editable.html(value); + $editable.html(value); - if ($('body').hasClass('editor_enable') && value !== fields_values[field_name]) { - $editable.trigger("content_changed"); - } + if ($('body').hasClass('editor_enable') && value !== fields_values[field_name]) { + $editable.trigger('content_changed'); } }; @@ -32,33 +32,26 @@ editor.Class.include({ this.on('rte:start', this, function () { this.$('form').hide(); - if (window.top.odoo[callback+"_editor"]) { - window.top.odoo[callback+"_editor"](this); + if (window.top.odoo[callback + '_editor']) { + window.top.odoo[callback + '_editor'](this); } - var $editable = $("#editable_area"); + var $editable = $('#editable_area'); setTimeout(function () { - $($editable.find("*").filter(function () {return !this.children.length;}).first()[0] || $editable) - .focusIn().trigger("mousedown").trigger("keyup"); + $($editable.find('*').filter(function () {return !this.children.length;}).first()[0] || $editable) + .focusIn().trigger('mousedown').trigger('keyup'); },0); $editable.on('content_changed', this, function () { - if (window.top.odoo[callback+"_downup"]) { - window.top.odoo[callback+"_downup"]($editable.prop('innerHTML')); + if (window.top.odoo[callback + '_downup']) { + window.top.odoo[callback + '_downup']($editable.prop('innerHTML')); } }); }); - this.on("snippets:ready", this, function () { - $(window.top).trigger("resize"); + return this._super.apply(this, arguments).then(function () { + $(window.top).trigger('resize'); // TODO check, probably useless }); - return this._super.apply(this, arguments); - } -}); - -snippet_editor.Class.include({ - _get_snippet_url: function () { - return snippets_url; } }); @@ -69,18 +62,89 @@ rte.Class.include({ config.airPopover.splice(7, 0, ['view', ['codeview']]); } return config; - } + }, }); translator.Class.include({ start: function () { var res = this._super.apply(this, arguments); $('button[data-action=save]').hide(); - if (window.top.odoo[callback+"_editor"]) { - window.top.odoo[callback+"_editor"](this); + if (window.top.odoo[callback + '_editor']) { + window.top.odoo[callback + '_editor'](this); } return res; - } + }, +}); }); +//============================================================================== + +odoo.define('web_editor.IframeRoot.instance', function (require) { +'use strict'; + +require('web.dom_ready'); +var iframeRootData = require('web_editor.IframeRoot'); + +var iframeRoot = new iframeRootData.IframeRoot(null); +return iframeRoot.attachTo(document.body).then(function () { + return iframeRoot; +}); +}); + +//============================================================================== + +odoo.define('web_editor.IframeRoot', function (require) { +'use strict'; + +var BodyManager = require('web_editor.BodyManager'); +var weContext = require('web_editor.context'); +var editor = require('web_editor.editor'); +var rootWidget = require('web_editor.root_widget'); +var translate = require('web_editor.translate'); + +var iframeRootRegistry = new rootWidget.RootWidgetRegistry(); + +var IframeRoot = BodyManager.extend({ + /** + * @override + * @todo this is somehow a duplicate of website features + */ + start: function () { + var defs = [this._super.apply(this, arguments)]; + + var ctx = weContext.getExtra(); + + if (ctx.editable && window.location.search.indexOf('enable_editor') >= 0) { + var editorInstance = new (editor.Class)(this); + defs.push(editorInstance.prependTo(this.$el)); + } + + if (ctx.edit_translations) { + var translator = new (translate.Class)(this, this.$('#wrapwrap')); + defs.push(translator.prependTo(this.$el)); + } + + return $.when.apply($, defs); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * As the IframeRoot instance is designed to be unique, the associated + * registry has been instantiated outside of the class and is simply + * returned here. + * + * @override + */ + _getRegistry: function () { + return iframeRootRegistry; + }, +}); + +return { + IframeRoot: IframeRoot, + iframeRootRegistry: iframeRootRegistry, +}; }); diff --git a/addons/web_editor/static/src/js/inline.js b/addons/web_editor/static/src/js/inline.js index 765ce7682df..ac0dee435ae 100644 --- a/addons/web_editor/static/src/js/inline.js +++ b/addons/web_editor/static/src/js/inline.js @@ -1,6 +1,7 @@ odoo.define('web_editor.inline', function (require) { 'use strict'; +var core = require('web.core'); var editor = require('web_editor.editor'); var rte = require('web_editor.rte'); var widget = require('web_editor.widget'); @@ -9,22 +10,22 @@ var snippet_editor = require('web_editor.snippet.editor'); widget.MediaDialog.include({ start: function () { - this._super.apply(this, arguments); this.$('[href="#editor-media-video"]').addClass('hidden'); - } + return this._super.apply(this, arguments); + }, }); editor.Class.include({ start: function () { - if (location.search.indexOf("enable_editor") !== -1) { + if (window.location.search.indexOf('enable_editor') !== -1) { this.on('rte:start', this, function () { // move the caret at the end of the text when click after all content - $("#wrapwrap").on('click', function (event) { - if ($(event.target).is("#wrapwrap") || $(event.target).is("#editable_area:empty")) { + $('#wrapwrap').on('click', function (event) { + if ($(event.target).is('#wrapwrap') || $(event.target).is('#editable_area:empty')) { _.defer(function () { - var node = $("#editable_area *") + var node = $('#editable_area *') .filter(function () { return this.textContent.match(/\S|\u00A0/); }) - .add($("#editable_area")) + .add($('#editable_area')) .last()[0]; $.summernote.core.range.create(node, $.summernote.core.dom.nodeLength(node)).select(); }); @@ -38,21 +39,22 @@ editor.Class.include({ snippet_editor.Class.include({ start: function () { - this._super.apply(this, arguments); - setTimeout(function () { - var $editable = $("#editable_area"); - transcoder.img_to_font($editable); - transcoder.style_to_class($editable); + _.defer(function () { + var $editable = $('#editable_area'); + transcoder.imgToFont($editable); + transcoder.styleToClass($editable); // fix outlook image rendering bug $editable.find('img[style*="width"], img[style*="height"]').removeAttr('height width'); }); + return this._super.apply(this, arguments); }, - clean_for_save: function () { + cleanForSave: function () { this._super.apply(this, arguments); - var $editable = $("#editable_area"); - transcoder.font_to_img($editable); - transcoder.class_to_style($editable); + + var $editable = $('#editable_area'); + transcoder.fontToImg($editable); + transcoder.classToStyle($editable); // fix outlook image rendering bug _.each(['width', 'height'], function (attribute) { @@ -65,24 +67,23 @@ snippet_editor.Class.include({ }, }); -window.top.odoo[callback+"_updown"] = function (value, fields_values) { - var $editable = $("#editable_area"); - value = value || ""; +var callback = window ? window['callback'] : undefined; +window.top.odoo[callback + '_updown'] = function (value, fields_values) { + var $editable = $('#editable_area'); + value = value || ''; if (value.indexOf('on_change_model_and_list') === -1 && value !== $editable.html()) { rte.history.recordUndo($editable, null, true); - if (snippet_editor.instance) { - snippet_editor.instance.make_active(false); - } + core.bus.trigger('deactivate_snippet'); $editable.html(value); - transcoder.img_to_font($editable); - transcoder.style_to_class($editable); + transcoder.imgToFont($editable); + transcoder.styleToClass($editable); // fix outlook image rendering bug $editable.find('img[style*="width"], img[style*="height"]').removeAttr('height width'); } else { - $editable.trigger("content_changed"); + $editable.trigger('content_changed'); } }; }); diff --git a/addons/web_editor/static/src/js/root_widget.js b/addons/web_editor/static/src/js/root_widget.js new file mode 100644 index 00000000000..98a14908f10 --- /dev/null +++ b/addons/web_editor/static/src/js/root_widget.js @@ -0,0 +1,155 @@ +odoo.define('web_editor.root_widget', function (require) { +'use strict'; + +var Class = require('web.Class'); +var dom = require('web.dom'); +var mixins = require('web.mixins'); +var Widget = require('web.Widget'); + +/** + * Specialized Widget which automatically instantiates child widgets to attach + * to internal DOM elements once it is started. The widgets to instantiate are + * known thanks to a linked registry which contains info about the widget + * classes and jQuery selectors to use to find the elements to attach them to. + */ +var RootWidget = Widget.extend({ + custom_events: _.extend({}, Widget.prototype.custom_events || {}, { + registry_update: '_onRegistryUpdate', + }), + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this._widgets = []; + this._listenToUpdates = false; + this._getRegistry().setParent(this); + }, + /** + * @override + * @see _attachComponents + */ + start: function () { + var defs = [this._super.apply(this, arguments)]; + + defs.push(this._attachComponents()); + this._listenToUpdates = true; + + return $.when.apply($, defs); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instantiates a child widget according to the given registry data. + * + * @private + * @param {Object} childInfo + * @param {function} childInfo.Widget - the widget class to instantiate + * @param {string} childInfo.selector + * the jQuery selector to use to find the internal DOM element which + * needs to be attached to the instantiated widget + * @param {jQuery} [$from] - only check DOM elements which are descendant of + * the given one. If not given, use this.$el. + * @returns {Deferred} + */ + _attachComponent: function (childInfo, $from) { + var self = this; + var $elements = dom.cssFind($from || this.$el, childInfo.selector); + var defs = _.map($elements, function (element) { + var w = new childInfo.Widget(self); + self._widgets.push(w); + return w.attachTo(element); + }); + return $.when.apply($, defs); + }, + /** + * Instantiates the child widgets that need to be according to the linked + * registry. + * + * @private + * @param {jQuery} [$from] - only check DOM elements which are descendant of + * the given one. If not given, use this.$el. + * @returns {Deferred} + */ + _attachComponents: function ($from) { + var self = this; + var childInfos = this._getRegistry().get(); + var defs = _.map(childInfos, function (childInfo) { + return self._attachComponent(childInfo, $from); + }); + return $.when.apply($, defs); + }, + /** + * Returns the `RootWidgetRegistry` instance that is linked to this + * `RootWidget` instance. + * + * @abstract + * @private + * @returns {RootWidgetRegistry} + */ + _getRegistry: function () {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the linked registry is updated after this `RootWidget` + * + * @private + * @param {OdooEvent} ev + */ + _onRegistryUpdate: function (ev) { + ev.stopPropagation(); + if (this._listenToUpdates) { + this._attachComponent(ev.data); + } + }, +}); + +var RootWidgetRegistry = Class.extend(mixins.EventDispatcherMixin, { + /** + * @constructor + */ + init: function () { + mixins.EventDispatcherMixin.init.call(this); + this._registry = []; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds an element to the registry (info of what and how to instantiate). + * + * @param {function} Widget - the widget class to instantiate + * @param {string} selector + * the jQuery selector to use to find the internal DOM element which + * needs to be attached to the instantiated widget + */ + add: function (Widget, selector) { + var registryInfo = { + Widget: Widget, + selector: selector, + }; + this._registry.push(registryInfo); + this.trigger_up('registry_update', registryInfo); + }, + /** + * Retrieves all the registry elements. + */ + get: function () { + return this._registry; + }, +}); + +return { + RootWidget: RootWidget, + RootWidgetRegistry: RootWidgetRegistry, +}; +}); diff --git a/addons/web_editor/static/src/js/snippets.animation.js b/addons/web_editor/static/src/js/snippets.animation.js deleted file mode 100644 index 47d62dda821..00000000000 --- a/addons/web_editor/static/src/js/snippets.animation.js +++ /dev/null @@ -1,119 +0,0 @@ -odoo.define('web_editor.snippets.animation', function (require) { -'use strict'; - -var Class = require('web.Class'); -var base = require('web_editor.base'); - -var registry = {}; -var ready = []; - -/** - * The Animation class provides a way for executing code once a website DOM element is loaded in the dom - * and handle the case where the website edit mode is triggered. - */ -var Animation = Class.extend({ - /** - * The selector attribute, if defined, allows to automatically create an instance of this animation on - * page load for each DOM element which matches this selector. The Animation $target element will then - * be that particular DOM element. This should be the main way of instantiating Animation elements. - */ - selector: false, - /** - * The $ method is a helper to search for DOM elements inside the animation DOM element target. - * @param selector of the element to search in the DOM element target - */ - $: function () { - return this.$el.find.apply(this.$el, arguments); - }, - /** - * The init method starts the animation, waiting for its dependencies to be initialized. - * The init method is called when instantiating the Animation thanks to the "new" keyword. - * @param dom - the DOM element the animation is targeting - * @param editable_mode - A boolean which is true if the page is in edition mode - */ - init: function (dom, editable_mode) { - this.$el = this.$target = $(dom); - this._start(editable_mode); - }, - /** - * The willStart method initializes the resources the start method will need to properly work. - * @param editable_mode - A boolean which is true if the page is in edition mode - * @return A deferred which is resolved once all the resources are loaded - */ - willStart: function (editable_mode) { - return $.when(); - }, - /** - * The abstract start method is intended to initialize the animation. - * The method should not be called directly as called automatically on animation initialization and - * on restart (@see Animation.init, @see Animation.restart). - * @param editable_mode - A boolean which is true if the page is in edition mode - * @param next argument will be the resources given by the willStart deferred (@see Animation.willStart) - */ - start: function (editable_mode) {}, - /** - * The abstract stop method is intended to destroy the animation and should basically restore the - * target to the way it was before the start method was called. - * Note: this method should be callable even if the start method was not called earlier. - */ - stop: function () {}, - /** - * The restart method stops the animation then starts it again, waiting for the eventual willStart - * deferred. - * @param editable_mode - A boolean which is true if the page is in edition mode - * @return A deferred which is resolved once the Animation resources/elements are (re)loaded/(re)initialized. - */ - restart: function (editable_mode) { - return $.when(this.stop()).then(this._start.bind(this, editable_mode)); - }, - /** - * The private _start method basically calls the start method after waiting for the end - * of the willStart method. - * @param editable_mode - A boolean which is true if the page is in edition mode - * @return A deferred which is resolved once the Animation resources/elements are loaded/initialized. - */ - _start: function (editable_mode) { - return this.willStart(editable_mode).then(this.start.bind(this, editable_mode)); - }, -}); - -/** - * Start animations when the website loading is finished - */ -base.ready().always(function () { - _.defer(start); -}); - -return { - Class: Animation, - registry: registry, - start: start, -}; - -/** - * The start function allows to create an Animation instance for each registered one for each DOM element - * which matches the "selector" key of these registered animations (@see Animation.selector). - * @param editable_mode - A boolean which is true if the page is in edition mode - * @param $init_target (optional) - a jQuery DOM element ; if given, only initialize the animations whose - * "selector" matches it - */ -function start(editable_mode, $init_target) { - _.each(registry, function (Animation) { - var selector = Animation.prototype.selector || ""; - var $target = $init_target ? $init_target.filter(selector) : $(selector); - - $target.each(function () { - var $snippet = $(this); - var animation = $snippet.data("snippet-view"); - if (!$snippet.parents("#oe_snippets").length - && !$snippet.parent("body").length - && !animation) { - ready.push($snippet); - $snippet.data("snippet-view", new Animation($snippet, editable_mode)); - } else if (animation) { - animation.restart(editable_mode); - } - }); - }); -} -}); diff --git a/addons/web_editor/static/src/js/tours/rte.js b/addons/web_editor/static/src/js/tours/rte.js index da4780202d3..f80704bf878 100644 --- a/addons/web_editor/static/src/js/tours/rte.js +++ b/addons/web_editor/static/src/js/tours/rte.js @@ -1,322 +1,328 @@ odoo.define('web_editor.tour', function (require) { - "use strict"; +'use strict'; - var tour = require("web_tour.tour"); - var base = require("web_editor.base"); - var snippet_editor = require('web_editor.snippet.editor'); +var core = require('web.core'); +var tour = require('web_tour.tour'); +var base = require('web_editor.base'); - var click_event = function (el, type) { - var evt = document.createEvent("MouseEvents"); - evt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el); - el.dispatchEvent(evt); - }; +/** + * Simulates a click event of given type on the given element. + * + * @param {DOMElement} el + * @param {string} type - 'click', 'mouseup', ... + */ +function simulateClickEvent(el, type) { + var evt = document.createEvent('MouseEvents'); + evt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el); + el.dispatchEvent(evt); +} - tour.register("rte", { - url: '/web_editor/field/html?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D', - test: true, - wait_for: base.ready(), - }, [{ - content: "Change html for this test", - trigger: "#editable_area", - run: function () { - var html = '\n'+ - '

    \n'+ - '
    \n'+ - '
    \n'+ - '
    \n'+ - '

    Batnae municipium in Anthemusia

    \n'+ - '

    Batnae municipium in Anthemusia conditum Macedonum manu priscorum ab Euphrate flumine brevi spatio disparatur, refertum mercatoribus opulentis, ubi annua sollemnitate prope Septembris initium mensis ad.

    \n'+ - '

    Quam quidem partem accusationis admiratus sum et moleste tuli potissimum esse Atratino datam. Neque enim decebat neque aetas.

    \n'+ - '

    Et hanc quidem praeter oppida multa duae civitates exornant Seleucia opus Seleuci regis, et Claudiopolis quam deduxit coloniam Claudius Caesar. Isaura enim antehac nimium potens, olim subversa ut rebellatrix.

    '+ - '

    Harum trium sententiarum nulli prorsus assentior.

    \n'+ - '
    \n'+ - '
    \n'+ - ' Odoo text and image block\n'+ - '
    \n'+ - '
    \n'+ - '
    \n'; - this.$anchor.html(html); - } - }, { - content: "simulate triple click and change text bg-color", - trigger: '#editable_area > section .row > div:first', - run: function () { - var $h1 = $('h1', this.$anchor); - $.summernote.core.range.create($h1[0].firstChild, 0, $('p', this.$anchor)[0], 0).select(); - click_event($h1[0], 'mouseup'); - } - }, { - content: "change text bg-color after triple click", - trigger: '.note-popover button[data-event="color"]', - extra_trigger: '#editable_area > section .row > div:first', - }, { - content: "change selection to change text color", - trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1 font', - run: function () { - $.summernote.core.range.create(this.$anchor[0].firstChild, 5, this.$anchor[0].firstChild, 10).select(); - click_event(this.$anchor[0], 'mouseup'); - } - }, { - content: "open color dropdown", - trigger: ".note-color button.dropdown-toggle", - }, { - content: "change text color", - trigger: ".btn-group.open button[data-event=foreColor]:first", - }, { - content: "change selection to change text bg-color again", - trigger: '#editable_area > section .row > div:first h1 font:eq(2)', - run: function () { - $.summernote.core.range.create(this.$anchor.prev()[0].firstChild, 3, this.$anchor[0].firstChild, 10).select(); - click_event(this.$anchor.prev()[0], 'mouseup'); - } - }, { - content: "open color dropdown", - trigger: ".note-color button.dropdown-toggle", - }, { - content: "change text backColor again", - trigger: "button[data-event=backColor]:visible:first", - }, { - content: "change selection (h1 and p) to change text color with class", - trigger: '#editable_area > section .row > div:first h1 font:eq(4)', - run: function () { - $.summernote.core.range.create(this.$anchor.prev()[0].firstChild, 3, this.$anchor.parent("h1").next("p")[0].firstChild, 30).select(); - click_event(this.$anchor.prev()[0], 'mouseup'); - } - }, { - content: "open color dropdown", - trigger: ".note-color button.dropdown-toggle", - }, { - content: "change text color again", - trigger: "button[data-event=foreColor]:visible:eq(3)", - }, { - content: "delete selection", - trigger: '.o_editable.note-editable.o_dirty', - extra_trigger: '#editable_area > section .row > div:first p font', - run: "keydown 46", // delete - }, { - content: "create an other selection to delete", - trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1', - extra_trigger: '.o_editable.note-editable.o_dirty', - run: function () { - $.summernote.core.range.createFromNode(this.$anchor.next("p")[0]).clean(); - $.summernote.core.range.create(this.$anchor.find('font:containsExact(ici)')[0].firstChild, 1, this.$anchor.next().next()[0].firstChild, 5).select(); - click_event(this.$anchor.find('font:last')[0], 'mouseup'); - }, - }, { - content: "clean and delete (backspace) an other selection", - trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1', - run: "keydown 8", // backspace - }, { - content: "an other selection", - trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) ) h1', - extra_trigger: '.o_editable.note-editable.o_dirty', - run: function () { - $.summernote.core.range.create(this.$anchor.find('font:first')[0].firstChild, 3, this.$anchor.next("p")[0].childNodes[2], 8).select(); - click_event(this.$anchor.find('font:first')[0], 'mouseup'); - }, - }, { - content: "delete an other selection", - trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) ) h1', - run: "keydown 46", - }, { - content: "undo", - trigger: '.note-image-popover button[data-event="undo"]', - extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(Bat) )', - }, { - content: "undo again", - trigger: '.note-air-popover button[data-event="undo"]', - extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) )', - }, { - content: "delete (backspace) after undo", - trigger: '.o_editable.note-editable.o_dirty', - extra_trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1', - run: "keydown 8", // backspace - }, { - content: "click on image", - trigger: '#editable_area > section .row > div:first img[style*="25%"]', - extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) )', - }, { - content: "Click on resize half", - trigger: '.note-image-popover:visible button[data-event="resize"][data-value="0.5"]', - }, { - content: "Click on edit picture", - trigger: '.note-image-popover:visible button[data-event="showImageDialog"]', - extra_trigger: '#editable_area > section .row > div:first img[style*="50%"]', - }, { - content: "Click on pictogram tab", - trigger: 'a[data-toggle="tab"]:contains(Pictogram)', - extra_trigger: '#editor-media-image', - }, { - content: "select a pictogram", - trigger: '#editor-media-icon.active span.fa:first', - }, { - content: "save pictogram", - trigger: 'button.o_save_button', - extra_trigger: '#editor-media-icon.active span.o_selected', - }, { - content: "select a size for the pictogram", - trigger: '.note-image-popover button[data-event="resizefa"][data-value="3"]', - }, { - content: "click on float right", - trigger: '.note-image-popover:visible button[data-event="floatMe"][data-value="right"]', - extra_trigger: '#wrapwrap span.fa-3x', - }, { - content: "click on create link", - trigger: '.note-image-popover:visible button[data-event="showLinkDialog"]', - extra_trigger: '#editable_area > section .row > div:first span.fa.pull-right', - }, { - content: "click on color style", - trigger: '.o_link_dialog_color > .o_link_dialog_color_item.btn-success', - extra_trigger: 'a#link-preview:containsRegex(/^]+><\\/span>$/) > span.fa.fa-3x.pull-right', - }, { - content: "insert a link url", - trigger: '#o_link_dialog_url_input', - extra_trigger: 'a#link-preview.btn', - run: "text http://www.odoo.com", - }, { - content: "change text label", - trigger: '#o_link_dialog_label_input', - run: "text ABC[IMG] DEF", - }, { - content: "save link", - trigger: 'button.o_save_button', - extra_trigger: 'a#link-preview.btn:containsRegex(/^ABC]+><\\/span> DEF$/)', - }, { - content: "click on other picture", - trigger: '#editable_area > section .row > div:last img', - extra_trigger: 'body:not(:has(#link-preview)) a.btn[href^="http://"]:has(span.fa.fa-3x.pull-right)', - }, { - content: "click on create link again", - trigger: '.note-image-popover:visible button[data-event="showLinkDialog"]', - extra_trigger: '#editable_area > section .row > div:first span.fa.pull-right', - }, { - content: "click on color style again", - trigger: '.o_link_dialog_color > .o_link_dialog_color_item.btn-success', - extra_trigger: 'a#link-preview:containsRegex(/^]+>$/) img', - }, { - content: "insert an email", - trigger: '#o_link_dialog_url_input', - extra_trigger: 'a#link-preview.btn', - run: "text test@test.test", - }, { - content: "save link", - trigger: 'button.o_save_button', - extra_trigger: 'a#link-preview.btn[href="mailto:test@test.test"]', - }, { - content: "select for triple enter then double backspace", - trigger: '#editable_area > section .row > div:first p:eq(2)', - extra_trigger: 'body:not(:has(#link-preview)) #editable_area > section .row > div:eq(1) > a > img', - run: function () { - var p = this.$anchor[0].firstChild; - $.summernote.core.range.create(p, p.textContent.length, p, p.textContent.length).select(); - click_event(p, 'mouseup'); - }, - }, { - content: "triple enter then double backspace", - trigger: '#editable_area > section .row > div:first p:eq(2)', - run: "keydown 66,13,66,13,13,8,8", // B enter B enter enter backspace backspace - }, { - content: "add ul content", - trigger: '#editable_area > section .row > div:first', - extra_trigger: 'body:not(:has(#editable_area > section .row > div:first p:eq(5), #editable_area > section .row > div:eq(3))) #editable_area > section .row > div:first p:eq(3)', - run: function () { - var html = '
      '+ - '\n
    • Batnae municipium.

    • '+ - '\n
    • Seleucia praeter.
    • '+ - '\n
    • Et hanc quidem.

    • '+ - '\n
    '; - this.$anchor.append(html); - var node = this.$anchor.find('ul li p:last')[0].firstChild; - $.summernote.core.range.create(node, 6).select(); - click_event(node, 'mouseup'); - } - }, { - content: "click on style dropdown", - trigger: '.note-air-popover .note-style button.dropdown-toggle', - extra_trigger: '#editable_area > section .row > div:first ul li p:first', - }, { - content: "select h3", - trigger: '.note-air-popover .note-style ul:visible a[data-value="h3"]', - }, { - content: "select h3", - trigger: '#editable_area > section .row > div:first > ul > li > h3', - run: function () { - var node = this.$anchor[0].firstChild; - $.summernote.core.range.create(node, 0).select(); - click_event(node, 'mouseup'); - } - }, { - content: "double tabulation", - trigger: '#editable_area > section .row > div:first > ul > li > h3', - run: "keydown 9,9", // tabulation - }, { - content: "click on order list", - trigger: '.note-air-popover button[data-event="insertOrderedList"]', - extra_trigger: '#editable_area > section .row > div:first ul > li > ul > li > ul > li > h3', - }, { - content: "select for enter in ul", - trigger: '#editable_area > section .row > div:first ul li > p:last', - run: function () { - this.$anchor[0].firstChild.textContent += ""; - $.summernote.core.range.create(this.$anchor[0].firstChild, 7).select(); - click_event(this.$anchor[0], 'mouseup'); - } - }, { - content: "enter in ul", - trigger: '#editable_area > section .row > div:first ul li > p:last', - run: "keydown 66,13", // enter - }, { - trigger: '#editable_area > section .row > div:first ul li > p:eq(1):containsRegex(/^municipium./)', - content: "backspace in list", - run: "keydown 8", - }, { - content: "end", - trigger: '#editable_area > section .row > div:first ul li p:eq(0):containsRegex(/^Batnae Bmunicipium.$/)', - }]); +tour.register('rte', { + url: '/web_editor/field/html?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D', + test: true, + wait_for: base.ready(), +}, [{ + content: "Change html for this test", + trigger: "#editable_area", + run: function () { + var html = '\n'+ + '
    \n'+ + '
    \n'+ + '
    \n'+ + '
    \n'+ + '

    Batnae municipium in Anthemusia

    \n'+ + '

    Batnae municipium in Anthemusia conditum Macedonum manu priscorum ab Euphrate flumine brevi spatio disparatur, refertum mercatoribus opulentis, ubi annua sollemnitate prope Septembris initium mensis ad.

    \n'+ + '

    Quam quidem partem accusationis admiratus sum et moleste tuli potissimum esse Atratino datam. Neque enim decebat neque aetas.

    \n'+ + '

    Et hanc quidem praeter oppida multa duae civitates exornant Seleucia opus Seleuci regis, et Claudiopolis quam deduxit coloniam Claudius Caesar. Isaura enim antehac nimium potens, olim subversa ut rebellatrix.

    '+ + '

    Harum trium sententiarum nulli prorsus assentior.

    \n'+ + '
    \n'+ + '
    \n'+ + ' Odoo text and image block\n'+ + '
    \n'+ + '
    \n'+ + '
    \n'; + this.$anchor.html(html); + } +}, { + content: "simulate triple click and change text bg-color", + trigger: '#editable_area > section .row > div:first', + run: function () { + var $h1 = $('h1', this.$anchor); + $.summernote.core.range.create($h1[0].firstChild, 0, $('p', this.$anchor)[0], 0).select(); + simulateClickEvent($h1[0], 'mouseup'); + } +}, { + content: "change text bg-color after triple click", + trigger: '.note-popover button[data-event="color"]', + extra_trigger: '#editable_area > section .row > div:first', +}, { + content: "change selection to change text color", + trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1 font', + run: function () { + $.summernote.core.range.create(this.$anchor[0].firstChild, 5, this.$anchor[0].firstChild, 10).select(); + simulateClickEvent(this.$anchor[0], 'mouseup'); + } +}, { + content: "open color dropdown", + trigger: ".note-color button.dropdown-toggle", +}, { + content: "change text color", + trigger: ".btn-group.open button[data-event=foreColor]:first", +}, { + content: "change selection to change text bg-color again", + trigger: '#editable_area > section .row > div:first h1 font:eq(2)', + run: function () { + $.summernote.core.range.create(this.$anchor.prev()[0].firstChild, 3, this.$anchor[0].firstChild, 10).select(); + simulateClickEvent(this.$anchor.prev()[0], 'mouseup'); + } +}, { + content: "open color dropdown", + trigger: ".note-color button.dropdown-toggle", +}, { + content: "change text backColor again", + trigger: "button[data-event=backColor]:visible:first", +}, { + content: "change selection (h1 and p) to change text color with class", + trigger: '#editable_area > section .row > div:first h1 font:eq(4)', + run: function () { + $.summernote.core.range.create(this.$anchor.prev()[0].firstChild, 3, this.$anchor.parent("h1").next("p")[0].firstChild, 30).select(); + simulateClickEvent(this.$anchor.prev()[0], 'mouseup'); + } +}, { + content: "open color dropdown", + trigger: ".note-color button.dropdown-toggle", +}, { + content: "change text color again", + trigger: "button[data-event=foreColor]:visible:eq(3)", +}, { + content: "delete selection", + trigger: '.o_editable.note-editable.o_dirty', + extra_trigger: '#editable_area > section .row > div:first p font', + run: "keydown 46", // delete +}, { + content: "create an other selection to delete", + trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1', + extra_trigger: '.o_editable.note-editable.o_dirty', + run: function () { + $.summernote.core.range.createFromNode(this.$anchor.next("p")[0]).clean(); + $.summernote.core.range.create(this.$anchor.find('font:containsExact(ici)')[0].firstChild, 1, this.$anchor.next().next()[0].firstChild, 5).select(); + simulateClickEvent(this.$anchor.find('font:last')[0], 'mouseup'); + }, +}, { + content: "clean and delete (backspace) an other selection", + trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1', + run: "keydown 8", // backspace +}, { + content: "an other selection", + trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) ) h1', + extra_trigger: '.o_editable.note-editable.o_dirty', + run: function () { + $.summernote.core.range.create(this.$anchor.find('font:first')[0].firstChild, 3, this.$anchor.next("p")[0].childNodes[2], 8).select(); + simulateClickEvent(this.$anchor.find('font:first')[0], 'mouseup'); + }, +}, { + content: "delete an other selection", + trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) ) h1', + run: "keydown 46", +}, { + content: "undo", + trigger: '.note-image-popover button[data-event="undo"]', + extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(Bat) )', +}, { + content: "undo again", + trigger: '.note-air-popover button[data-event="undo"]', + extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) )', +}, { + content: "delete (backspace) after undo", + trigger: '.o_editable.note-editable.o_dirty', + extra_trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1', + run: "keydown 8", // backspace +}, { + content: "click on image", + trigger: '#editable_area > section .row > div:first img[style*="25%"]', + extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) )', +}, { + content: "Click on resize half", + trigger: '.note-image-popover:visible button[data-event="resize"][data-value="0.5"]', +}, { + content: "Click on edit picture", + trigger: '.note-image-popover:visible button[data-event="showImageDialog"]', + extra_trigger: '#editable_area > section .row > div:first img[style*="50%"]', +}, { + content: "Click on pictogram tab", + trigger: 'a[data-toggle="tab"]:contains(Pictogram)', + extra_trigger: '#editor-media-image', +}, { + content: "select a pictogram", + trigger: '#editor-media-icon.active span.fa:first', +}, { + content: "save pictogram", + trigger: 'button.o_save_button', + extra_trigger: '#editor-media-icon.active span.o_selected', +}, { + content: "select a size for the pictogram", + trigger: '.note-image-popover button[data-event="resizefa"][data-value="3"]', +}, { + content: "click on float right", + trigger: '.note-image-popover:visible button[data-event="floatMe"][data-value="right"]', + extra_trigger: '#wrapwrap span.fa-3x', +}, { + content: "click on create link", + trigger: '.note-image-popover:visible button[data-event="showLinkDialog"]', + extra_trigger: '#editable_area > section .row > div:first span.fa.pull-right', +}, { + content: "click on color style", + trigger: '.o_link_dialog_color > .o_link_dialog_color_item.btn-success', + extra_trigger: 'a#link-preview:containsRegex(/^]+><\\/span>$/) > span.fa.fa-3x.pull-right', +}, { + content: "insert a link url", + trigger: '#o_link_dialog_url_input', + extra_trigger: 'a#link-preview.btn', + run: "text http://www.odoo.com", +}, { + content: "change text label", + trigger: '#o_link_dialog_label_input', + run: "text ABC[IMG] DEF", +}, { + content: "save link", + trigger: 'button.o_save_button', + extra_trigger: 'a#link-preview.btn:containsRegex(/^ABC]+><\\/span> DEF$/)', +}, { + content: "click on other picture", + trigger: '#editable_area > section .row > div:last img', + extra_trigger: 'body:not(:has(#link-preview)) a.btn[href^="http://"]:has(span.fa.fa-3x.pull-right)', +}, { + content: "click on create link again", + trigger: '.note-image-popover:visible button[data-event="showLinkDialog"]', + extra_trigger: '#editable_area > section .row > div:first span.fa.pull-right', +}, { + content: "click on color style again", + trigger: '.o_link_dialog_color > .o_link_dialog_color_item.btn-success', + extra_trigger: 'a#link-preview:containsRegex(/^]+>$/) img', +}, { + content: "insert an email", + trigger: '#o_link_dialog_url_input', + extra_trigger: 'a#link-preview.btn', + run: "text test@test.test", +}, { + content: "save link", + trigger: 'button.o_save_button', + extra_trigger: 'a#link-preview.btn[href="mailto:test@test.test"]', +}, { + content: "select for triple enter then double backspace", + trigger: '#editable_area > section .row > div:first p:eq(2)', + extra_trigger: 'body:not(:has(#link-preview)) #editable_area > section .row > div:eq(1) > a > img', + run: function () { + var p = this.$anchor[0].firstChild; + $.summernote.core.range.create(p, p.textContent.length, p, p.textContent.length).select(); + simulateClickEvent(p, 'mouseup'); + }, +}, { + content: "triple enter then double backspace", + trigger: '#editable_area > section .row > div:first p:eq(2)', + run: "keydown 66,13,66,13,13,8,8", // B enter B enter enter backspace backspace +}, { + content: "add ul content", + trigger: '#editable_area > section .row > div:first', + extra_trigger: 'body:not(:has(#editable_area > section .row > div:first p:eq(5), #editable_area > section .row > div:eq(3))) #editable_area > section .row > div:first p:eq(3)', + run: function () { + var html = '
      '+ + '\n
    • Batnae municipium.

    • '+ + '\n
    • Seleucia praeter.
    • '+ + '\n
    • Et hanc quidem.

    • '+ + '\n
    '; + this.$anchor.append(html); + var node = this.$anchor.find('ul li p:last')[0].firstChild; + $.summernote.core.range.create(node, 6).select(); + simulateClickEvent(node, 'mouseup'); + } +}, { + content: "click on style dropdown", + trigger: '.note-air-popover .note-style button.dropdown-toggle', + extra_trigger: '#editable_area > section .row > div:first ul li p:first', +}, { + content: "select h3", + trigger: '.note-air-popover .note-style ul:visible a[data-value="h3"]', +}, { + content: "select h3", + trigger: '#editable_area > section .row > div:first > ul > li > h3', + run: function () { + var node = this.$anchor[0].firstChild; + $.summernote.core.range.create(node, 0).select(); + simulateClickEvent(node, 'mouseup'); + } +}, { + content: "double tabulation", + trigger: '#editable_area > section .row > div:first > ul > li > h3', + run: "keydown 9,9", // tabulation +}, { + content: "click on order list", + trigger: '.note-air-popover button[data-event="insertOrderedList"]', + extra_trigger: '#editable_area > section .row > div:first ul > li > ul > li > ul > li > h3', +}, { + content: "select for enter in ul", + trigger: '#editable_area > section .row > div:first ul li > p:last', + run: function () { + this.$anchor[0].firstChild.textContent += ""; + $.summernote.core.range.create(this.$anchor[0].firstChild, 7).select(); + simulateClickEvent(this.$anchor[0], 'mouseup'); + } +}, { + content: "enter in ul", + trigger: '#editable_area > section .row > div:first ul li > p:last', + run: "keydown 66,13", // enter +}, { + trigger: '#editable_area > section .row > div:first ul li > p:eq(1):containsRegex(/^municipium./)', + content: "backspace in list", + run: "keydown 8", +}, { + content: "end", + trigger: '#editable_area > section .row > div:first ul li p:eq(0):containsRegex(/^Batnae Bmunicipium.$/)', +}]); - tour.register('rte_inline', { - url: '/web_editor/field/html/inline?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D', - test: true, - wait_for: base.ready(), - }, [{ - content: "Change html for this test", - trigger: "#editable_area", - run: function () { - var html = '\n'+ - '
    \n'+ - ' \n'+ - ' \n'+ - ' \n'+ - ' \n'+ - ' \n'+ - ' \n'+ - ' \n'+ - '
    \n'+ - ' Your Logo\n'+ - ' \n'+ - ' \n'+ - ' \n'+ - '
    \n'+ - '
    '; - this.$anchor.html(html); - } - }, { - content: "call clean for save", - trigger: '#wrapwrap table', - run: function () { - snippet_editor.instance.clean_for_save(); - } - }, { - content: "check the image style", - trigger: '#wrapwrap img:first[width][height][style*="-radius"][style*="1px"][style*="padding"]', - }, { - content: "check the font image src", - trigger: '#wrapwrap img:eq(1)[src^="/web_editor/font_to_img/"][src$="/rgb(51,122,183)/28"]', - }, { - content: "check the font class to css", - trigger: '#wrapwrap img:eq(1)[height]:not([class*="fa"])', - }, { - content: "check the second font class to css", - trigger: '#wrapwrap img:eq(2)[style*="float: right"]', - }]); +tour.register('rte_inline', { + url: '/web_editor/field/html/inline?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D', + test: true, + wait_for: base.ready(), +}, [{ + content: "Change html for this test", + trigger: "#editable_area", + run: function () { + var html = '\n'+ + '
    \n'+ + ' \n'+ + ' \n'+ + ' \n'+ + ' \n'+ + ' \n'+ + ' \n'+ + ' \n'+ + '
    \n'+ + ' Your Logo\n'+ + ' \n'+ + ' \n'+ + ' \n'+ + '
    \n'+ + '
    '; + this.$anchor.html(html); + } +}, { + content: "call clean for save", + trigger: '#wrapwrap table', + run: function () { + core.bus.trigger('snippet_editor_clean_for_save'); + } +}, { + content: "check the image style", + trigger: '#wrapwrap img:first[width][height][style*="-radius"][style*="1px"][style*="padding"]', +}, { + content: "check the font image src", + trigger: '#wrapwrap img:eq(1)[src^="/web_editor/font_to_img/"][src$="/rgb(51,122,183)/28"]', +}, { + content: "check the font class to css", + trigger: '#wrapwrap img:eq(1)[height]:not([class*="fa"])', +}, { + content: "check the second font class to css", + trigger: '#wrapwrap img:eq(2)[style*="float: right"]', +}]); }); diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js index b1b28d78e83..a1fbd0753a6 100644 --- a/addons/web_editor/static/src/js/widgets/ace.js +++ b/addons/web_editor/static/src/js/widgets/ace.js @@ -3,17 +3,22 @@ odoo.define('web_editor.ace', function (require) { var ajax = require('web.ajax'); var core = require('web.core'); -var Dialog = require("web.Dialog"); +var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); -var base = require('web_editor.base'); -var local_storage = require('web.local_storage'); -var session = require("web.session"); +var weContext = require('web_editor.context'); +var localStorage = require('web.local_storage'); +var session = require('web.session'); -var qweb = core.qweb; var _t = core._t; -ajax.loadXML('/web_editor/static/src/xml/ace.xml', qweb); - +/** + * Formats a content-check result (@see checkXML, checkLESS). + * + * @param {boolean} isValid + * @param {integer} [errorLine] needed if isValid is false + * @param {string} [errorMessage] needed if isValid is false + * @returns {Object} + */ function _getCheckReturn(isValid, errorLine, errorMessage) { return { isValid: isValid, @@ -23,17 +28,22 @@ function _getCheckReturn(isValid, errorLine, errorMessage) { }, }; } - +/** + * Checks the syntax validity of some XML. + * + * @param {string} xml + * @returns {Object} @see _getCheckReturn + */ function checkXML(xml) { - if (typeof window.DOMParser != "undefined") { - var xmlDoc = (new window.DOMParser()).parseFromString(xml, "text/xml"); - var error = xmlDoc.getElementsByTagName("parsererror"); + if (typeof window.DOMParser != 'undefined') { + var xmlDoc = (new window.DOMParser()).parseFromString(xml, 'text/xml'); + var error = xmlDoc.getElementsByTagName('parsererror'); if (error.length > 0) { return _getCheckReturn(false, parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), error[0].innerHTML); } - } else if (typeof window.ActiveXObject != "undefined" && new window.ActiveXObject("Microsoft.XMLDOM")) { - var xmlDocIE = new window.ActiveXObject("Microsoft.XMLDOM"); - xmlDocIE.async = "false"; + } else if (typeof window.ActiveXObject != 'undefined' && new window.ActiveXObject('Microsoft.XMLDOM')) { + var xmlDocIE = new window.ActiveXObject('Microsoft.XMLDOM'); + xmlDocIE.async = 'false'; xmlDocIE.loadXML(xml); if (xmlDocIE.parseError.line > 0) { return _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason); @@ -41,11 +51,21 @@ function checkXML(xml) { } return _getCheckReturn(true); } - +/** + * Formats some XML so that it has proper indentation and structure. + * + * @param {string} xml + * @returns {string} formatted xml + */ function formatXML(xml) { return window.vkbeautify.xml(xml, 4); } - +/** + * Checks the syntax validity of some LESS. + * + * @param {string} less + * @returns {Object} @see _getCheckReturn + */ var checkLESS = (function () { var mapping = { '{': '}', '}': '{', @@ -75,55 +95,52 @@ var checkLESS = (function () { return _getCheckReturn(true); }; })(); - +/** + * Formats some LESS so that it has proper indentation and structure. + * + * @todo Right now, this does return the given LESS content, untouched. + * @param {string} less + * @returns {string} formatted less + */ function formatLESS(less) { return less; } /** - * The ViewEditor Widget allow to visualize resources (by default, XML views) and edit them. + * Allows to visualize resources (by default, XML views) and edit them. */ var ViewEditor = Widget.extend({ template: 'web_editor.ace_view_editor', + xmlDependencies: ['/web_editor/static/src/xml/ace.xml'], events: { - "click .o_ace_type_switcher_choice": function (e) { - e.preventDefault(); - this.switchType($(e.target).data("type")); - }, - 'change .o_res_list': function () { - this.displayResource(this.selectedResource()); - }, - 'click .js_include_bundles': function (e) { - this.options.includeBundles = $(e.target).prop("checked"); - this.loadResources().then(this._updateViewSelectDOM.bind(this)); - }, - 'click .js_include_all_less': function (e) { - this.options.includeAllLess = $(e.target).prop("checked"); - this.loadResources().then(this._updateViewSelectDOM.bind(this)); - }, - 'click button[data-action=save]': 'saveResources', - "click button[data-action=\"reset\"]": function () { - var self = this; - Dialog.confirm(this, _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."), { - title: _t("Careful !"), - confirm_callback: function () { - self.resetResource(self.selectedResource()); - }, - }); - }, - 'click button[data-action=format]': 'formatResource', - 'click button[data-action=close]': 'do_hide', + 'click .o_ace_type_switcher_choice': '_onTypeChoice', + 'change .o_res_list': '_onResChange', + 'click .js_include_bundles': '_onIncludeBundlesChange', + 'click .js_include_all_less': '_onIncludeAllLessChange', + 'click button[data-action=save]': '_onSaveClick', + 'click button[data-action=reset]': '_onResetClick', + 'click button[data-action=format]': '_onFormatClick', + 'click button[data-action=close]': '_onCloseClick', }, + /** - * The init method should initialize the parameters of which information the ace editor will - * have to load. - * @param parent: the parent element of the editor widget. - * @param viewKey: xml_id of the view whose linked resources are to be loaded. - Also allow to receive the id directly. - * @param options: an object containing some options - * - initialResID: a specific view ID to load on start (otherwise the main view ID - * associated with the specified viewKey will be used). - * - includeBundles: whether or not the assets bundles templates needs to be loaded. + * Initializes the parameters so that the ace editor knows which information + * it has to load. + * + * @constructor + * @param {Widget} parent + * @param {string|integer} viewKey + * xml_id or id of the view whose linked resources have to be loaded. + * @param {Object} [options] + * @param {string|integer} [options.initialResID] + * a specific view ID / LESS URL to load on start (otherwise the main + * view ID associated with the specified viewKey will be used) + * @param {string} [options.position=right] + * @param {boolean} [options.doNotLoadViews=false] + * @param {boolean} [options.doNotLoadLess=false] + * @param {boolean} [options.includeBundles=false] + * @param {boolean} [options.includeAllLess=false] + * @param {string[]} [options.defaultBundlesRestriction] */ init: function (parent, viewKey, options) { this._super.apply(this, arguments); @@ -140,15 +157,17 @@ var ViewEditor = Widget.extend({ this.resources = {xml: {}, less: {}}; this.editingSessions = {xml: {}, less: {}}; - this.currentType = "xml"; + this.currentType = 'xml'; // Alias this.views = this.resources.xml; this.less = this.resources.less; }, /** - * The willStart method is in charge of loading everything the ace library needs to work. - * It also loads the resources to visualize. See @loadResources. + * Loads everything the ace library needs to work. + * It also loads the resources to visualize (@see _loadResources). + * + * @override */ willStart: function () { var js_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () { @@ -158,33 +177,34 @@ var ViewEditor = Widget.extend({ ajax.loadJS('/web/static/lib/ace/theme-monokai.js') ); }); - return $.when(this._super.apply(this, arguments), js_def, this.loadResources()); + return $.when(this._super.apply(this, arguments), js_def, this._loadResources()); }, /** - * The start method is in charge of initializing the library and initial view once the DOM is - * ready. It also initializes the resize feature of the ace editor. - * @return a deferred which is resolved when the widget DOM content is fully loaded. + * Initializes the library and initial view once the DOM is ready. It also + * initializes the resize feature of the ace editor. + * + * @override */ start: function () { - this.$viewEditor = this.$("#ace-view-editor"); - this.$editor = this.$(".ace_editor"); + this.$viewEditor = this.$('#ace-view-editor'); + this.$editor = this.$('.ace_editor'); - this.$typeSwitcherChoices = this.$(".o_ace_type_switcher_choice"); - this.$typeSwitcherBtn = this.$(".o_ace_type_switcher > .dropdown-toggle"); + this.$typeSwitcherChoices = this.$('.o_ace_type_switcher_choice'); + this.$typeSwitcherBtn = this.$('.o_ace_type_switcher > .dropdown-toggle'); this.$lists = { - xml: this.$("#ace-view-list"), - less: this.$("#ace-less-list") + xml: this.$('#ace-view-list'), + less: this.$('#ace-less-list') }; - this.$includeBundlesArea = this.$(".oe_include_bundles"); - this.$includeAllLessArea = this.$(".o_include_all_less"); - this.$viewID = this.$("#ace-view-id > span"); + this.$includeBundlesArea = this.$('.oe_include_bundles'); + this.$includeAllLessArea = this.$('.o_include_all_less'); + this.$viewID = this.$('#ace-view-id > span'); - this.$formatButton = this.$("button[data-action=\"format\"]"); - this.$resetButton = this.$("button[data-action=\"reset\"]"); + this.$formatButton = this.$('button[data-action=format]'); + this.$resetButton = this.$('button[data-action=reset]'); this.aceEditor = window.ace.edit(this.$viewEditor[0]); - this.aceEditor.setTheme("ace/theme/monokai"); + this.aceEditor.setTheme('ace/theme/monokai'); var refX = 0; var resizing = false; @@ -197,35 +217,35 @@ var ViewEditor = Widget.extend({ var initType; if (this.options.initialResID) { initResID = this.options.initialResID; - initType = (_.isString(initResID) && initResID[0] === '/') ? "less" : "xml"; + initType = (_.isString(initResID) && initResID[0] === '/') ? 'less' : 'xml'; } else { if (!this.options.doNotLoadLess) { initResID = this.sorted_less[0][1][0].url; // first bundle, less files, first one - initType = "less"; + initType = 'less'; } if (!this.options.doNotLoadViews) { - initResID = (typeof this.viewKey === "number" ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id); - initType = "xml"; + initResID = (typeof this.viewKey === 'number' ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id); + initType = 'xml'; } } if (initResID) { - this.displayResource(initResID, initType); + this._displayResource(initResID, initType); } if (!this.sorted_views.length || !this.sorted_less.length) { _.defer((function () { - this.switchType(this.sorted_views.length ? "xml" : "less"); - this.$typeSwitcherBtn.parent(".btn-group").addClass("hidden"); + this._switchType(this.sorted_views.length ? 'xml' : 'less'); + this.$typeSwitcherBtn.parent('.btn-group').addClass('hidden'); }).bind(this)); } - $(document).on("mouseup.ViewEditor", stopResizing.bind(this)).on("mousemove.ViewEditor", updateWidth.bind(this)); + $(document).on('mouseup.ViewEditor', stopResizing.bind(this)).on('mousemove.ViewEditor', updateWidth.bind(this)); if (this.options.position === 'left') { this.$('.ace_scroller').after($('
    ').addClass('ace_resize_bar')); this.$('.ace_gutter').css({'cursor': 'default'}); - this.$el.on("mousedown.ViewEditor", ".ace_resize_bar", startResizing.bind(this)); + this.$el.on('mousedown.ViewEditor', '.ace_resize_bar', startResizing.bind(this)); } else { - this.$el.on("mousedown.ViewEditor", ".ace_gutter", startResizing.bind(this)); + this.$el.on('mousedown.ViewEditor', '.ace_gutter', startResizing.bind(this)); } resizeEditor.call(this, readEditorWidth.call(this)); @@ -239,10 +259,10 @@ var ViewEditor = Widget.extend({ this.$el.width(width); } function storeEditorWidth() { - local_storage.setItem('ace_editor_width', this.$el.width()); + localStorage.setItem('ace_editor_width', this.$el.width()); } function readEditorWidth() { - var width = local_storage.getItem('ace_editor_width'); + var width = localStorage.getItem('ace_editor_width'); return parseInt(width || 720, 10); } function startResizing(e) { @@ -266,20 +286,102 @@ var ViewEditor = Widget.extend({ } }, /** - * The destroy method unbinds custom events binded to the document element. + * @override */ destroy: function () { this._super.apply(this, arguments); - this.$el.off(".ViewEditor"); - $(document).off(".ViewEditor"); + this.$el.off('.ViewEditor'); + $(document).off('.ViewEditor'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Initializes a text editor for the specified resource. + * + * @private + * @param {integer|string} resID - the ID/URL of the view/less file + * @param {string} [type] (default to the currently selected one) + * @returns {ace.EditSession} + */ + _buildEditingSession: function (resID, type) { + var self = this; + type = type || this.currentType; + var editingSession = new window.ace.EditSession(this.resources[type][resID].arch); + editingSession.setUseWorker(false); + editingSession.setMode('ace/mode/' + (type || this.currentType)); + editingSession.setUndoManager(new window.ace.UndoManager()); + editingSession.on('change', function () { + _.defer(function () { + self._toggleDirtyInfo(resID); + self._showErrorLine(); + }); + }); + return editingSession; }, /** - * The loadResources method is in charge of loading data the ace editor will vizualize - * and of processing them. - * Default behavior is loading the activate views, index them and build their hierarchy. - * @return a deferred which is resolved once everything is loaded and processed. + * Forces the view/less file identified by its ID/URL to be displayed in the + * editor. The method will update the resource select DOM element as well if + * necessary. + * + * @private + * @param {integer|string} resID + * @param {string} [type] - the type of resource (either 'xml' or 'less') */ - loadResources: function () { + _displayResource: function (resID, type) { + if (type) { + this._switchType(type); + } + + var editingSession = this.editingSessions[this.currentType][resID]; + if (!editingSession) { + editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID); + } + this.aceEditor.setSession(editingSession); + + if (this.currentType === 'xml') { + this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].xml_id)); + } else { + this.$viewID.text(_.str.sprintf(_t("Less file: %s"), resID)); + } + this.$lists[this.currentType].select2('val', resID); + + this.$resetButton.toggleClass('hidden', this.currentType === 'xml' || !this.less[resID].customized); + }, + /** + * Formats the current resource being vizualized. + * (@see formatXML, formatLESS) + * + * @private + */ + _formatResource: function () { + var res = this.aceEditor.getValue(); + var check = (this.currentType === 'xml' ? checkXML : checkLESS)(res); + if (check.isValid) { + this.aceEditor.setValue((this.currentType === 'xml' ? formatXML : formatLESS)(res)); + } else { + this._showErrorLine(check.error.line, check.error.message, this._getSelectedResource()); + } + }, + /** + * Returns the currently selected resource data. + * + * @private + * @returns {integer|string} view ID or less file URL + */ + _getSelectedResource: function () { + return this.$lists[this.currentType].select2('val'); + }, + /** + * Loads data the ace editor will vizualize and process it. Default behavior + * is loading the activate views, index them and build their hierarchy. + * + * @private + * @returns {Deferred} + */ + _loadResources: function () { // Reset resources this.resources = {xml: {}, less: {}}; this.editingSessions = {xml: {}, less: {}}; @@ -288,7 +390,7 @@ var ViewEditor = Widget.extend({ // Load resources return this._rpc({ - route: "/web_editor/get_assets_editor_resources", + route: '/web_editor/get_assets_editor_resources', params: { key: this.viewKey, get_views: !this.options.doNotLoadViews, @@ -305,7 +407,7 @@ var ViewEditor = Widget.extend({ // Only keep the active views and index them by ID. _.extend(this.views, _.indexBy(_.filter(views, function (view) { return view.active; - }), "id")); + }), 'id')); // Initialize a 0 level for each view and assign them an array containing their children. var self = this; @@ -347,145 +449,30 @@ var ViewEditor = Widget.extend({ var self = this; _.each(less, function (bundleInfos) { _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; }); - _.extend(self.less, _.indexBy(bundleInfos[1], "url")); + _.extend(self.less, _.indexBy(bundleInfos[1], 'url')); }); } }, /** - * The private _updateViewSelectDOM method purpose is to render the content of the view/file - * select DOM element according to current widget data. + * Forces the view/less file identified by its ID/URL to be reset to the way + * it was before the user started editing it. + * + * @todo views reset is not supported yet + * + * @private + * @param {integer|string} [resID] (default to the currently selected one) + * @param {string} [type] (default to the currently selected one) + * @returns {Deferred} */ - _updateViewSelectDOM: function () { - var currentId = this.selectedResource(); - - var self = this; - this.$lists.xml.empty(); - _.each(this.sorted_views, function (view) { - self.$lists.xml.append($("", { - label: bundleInfos[0].name, - }).appendTo(self.$lists.less); - _.each(bundleInfos[1], function (lessInfo) { - var name = lessInfo.url.substring(_.lastIndexOf(lessInfo.url, "/") + 1, lessInfo.url.length - 5); - $optgroup.append($("