From 2972976962617d4b8a0113bae58c640ab41cdff8 Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Thu, 29 Jun 2017 16:38:34 +0200 Subject: [PATCH] [REF] web_editor, website, *: complete refactoring * mass_mailing, payment, point_of_sale, portal, survey, web, web_tour, website_blog, website_crm_partner_assign, website_event, website_event_questions, website_form, website_forum, website_gengo, website_hr_recruitment, website_links, website_mail, website_mail_channel, website_mass_mailing, website_quote, website_sale, website_sale_options, website_slides, website_twitter This commit reviews the whole "JS side" of the web_editor and website apps. This is a first step to be able to improve them with new and better functionnalities; this commit is not supposed to change any visual behavior. The main goal was to achieve a structure similar to the backend one. Now, the frontend side also has a root widget (like the WebClient) and all other widgets are attached to it one way or another. This allows the benefits of using the 'trigger_up' functionnality for example. As RPC are now mainly done with the `this._rpc` functionnality (being possible thanks to the parent hierarchy), the frontend will also be possible to test thanks to QUnit in a future update (besides the "text" editor side which still requires a refactoring to be able to do that). --- Here are some of the changes: (-) conventions and documentation The code has been updated to follow JS conventions and a lot of code has been commented (around +2000 lines of comment). This also means that lots of functions have been renamed to use camelCase or simply to make their name understandable. See https://github.com/odoo/odoo/wiki/Javascript-coding-guidelines. (-) deprecated: web_editor.base The "web_editor.base" module has been split and does not force the modules which require it to wait for DOM ready anymore. This was indeed slowing loading times, but also prevented to use some modules in some contexts (see the LESS editor use in web_studio which is the subject of another task). Now the "editor context" can be got thanks to the "web_editor.context" JS module with its "get" function. The "web_editor.base" module should probably not be used anymore (see its code and recent updates). (-) new: web.dom_ready If a JS module should wait for the DOM to be ready to be executed, a new JS module has been created: "web.dom_ready". This should always be used in a module which only want to instantiate stuff. Do not extend (or worst, include) classes after DOM ready. (-) website.website The "website.website" module has been split. "website.website" does not return anything useful anymore, it just initialize some miscellaneous stuff, without waiting for the DOM to be ready. You might want to check "website.utils", "website.content.compatibility" or `WebsiteRoot`. Also `website.form` has been deleted (use `this._rpc`), so has been `website.error`. `website.prompt` will be removed in a future update to be replaced by `Dialog.prompt`. (-) widgets are great Many classes which were not widgets are now widgets. This allows them to use the 'events', the 'xmlDependencies' and the 'this._rpc' features for example. Here are some of the main ones: - Snippet options: these were classes with a `$el` for the menu element and `$target` for the customized element. This is still the case but following standard `Widget` structure (one exception: using `this.$(...)` searches in the `$target` as before this update). - Snippet animations: instead of class instances with a `$target` element which can be `start` and `stop`, these are now standard widgets which can be `start` and `destroy`. `this.$target` is an alias to `this.$el` for ease of compatibility. - Snippet editors: instead of class instances in charge of an editor overlay, these are now widgets. Each "child" snippet editor is properly attached as a "child", which allows editors to communicate and to be properly destroyed. (-) root widgets and website navbar The frontend is different of the backend. In the backend, the page has an empty element and all the components are instantiated from parent to children (i.e. the `WebClient` is instantiated and is in charge of instantiating the `ControlPanel`, etc). The frontend cannot work like that on page loadings as they are way more frequent than in the backend and we do not want them to flicker. A frontend page is loaded as a element which already contains the website navbar and its menus and the whole content page. JS code has to be "attached" to these existing elements. This is possible thanks to the `RootWidget` instances and the specialized `WebsiteRoot`, `IframeRoot` and `WebsiteNavbar` (see code for details). (-) lazy loading No more (or at least a lot less) XML/JS has to be loaded on page loading, thanks to the use of the `Widget.xmlDependencies` feature. XML which have to be lazy loaded is loaded only on related Widget instantiation if necessary, which allows to execute a lot of JS code before the DOM is ready and to start many widgets on DOM ready (not later). A visual benefit of this is clicking on the 'edit' button as soon as it is possible: before this commit, this was sometimes not doing anything as event handlers were not binded yet. Still a possible exception: loading the session and locales. This may be asynchronous stuff which is still required before widget instantiations but this will be the subject of another task. (-) deprecated code and code location More than reviewing code and organizing it, many apparent dead code was removed. More importantly, mislocated code was put in the right app. This is the case for snippet animations which is a concept for website apps but was defined in the web_editor app, or some translation concepts which were part of website but should have been part of web_editor. --- There are probably more things to say about this commit but I will let the comments speak for those. --- .../static/src/js/mass_mailing_editor.js | 48 +- .../src/js/payment_transaction_portal.js | 6 +- .../static/src/js/pos.web_editor.js | 5 +- addons/point_of_sale/views/pos_templates.xml | 32 +- addons/portal/static/src/js/portal.js | 13 +- addons/portal/views/assets.xml | 21 +- addons/survey/static/src/js/survey.js | 1 + addons/survey/static/src/js/survey_result.js | 2 +- addons/web/static/src/js/core/dom.js | 11 +- .../web/static/src/js/report/report.editor.js | 41 +- addons/web/static/src/less/report.editor.less | 2 +- .../static/src/js/backend/fields.js | 12 +- addons/web_editor/static/src/js/base.js | 73 +- .../static/src/js/content/body_manager.js | 33 + .../web_editor/static/src/js/editor/editor.js | 282 ++- addons/web_editor/static/src/js/editor/rte.js | 636 +++-- .../static/src/js/editor/rte.summernote.js | 101 +- .../static/src/js/editor/snippets.editor.js | 2104 +++++++++------- .../static/src/js/editor/snippets.options.js | 2174 ++++++++++------- .../static/src/js/editor/summernote.js | 287 +-- .../static/src/js/editor/transcoder.js | 166 +- .../static/src/js/editor/translator.js | 426 ++-- addons/web_editor/static/src/js/iframe.js | 136 +- addons/web_editor/static/src/js/inline.js | 51 +- .../web_editor/static/src/js/root_widget.js | 155 ++ .../static/src/js/snippets.animation.js | 119 - addons/web_editor/static/src/js/tours/rte.js | 638 ++--- .../web_editor/static/src/js/widgets/ace.js | 804 +++--- .../static/src/js/widgets/widgets.js | 361 +-- .../src/less/web_editor.ui.components.less | 3 +- .../static/src/less/web_editor.ui.less | 4 +- addons/web_editor/static/src/xml/editor.xml | 2 +- addons/web_editor/static/src/xml/snippets.xml | 17 - .../web_editor/static/src/xml/translator.xml | 15 + addons/web_editor/views/editor.xml | 15 +- addons/web_editor/views/iframe.xml | 4 +- addons/web_tour/static/src/js/tour_manager.js | 2 +- addons/website/doc/website.snippet.rst | 29 +- .../website/static/src/js/backend/button.js | 19 +- .../static/src/js/backend/dashboard.js | 25 +- .../static/src/js/content/compatibility.js | 38 + .../src/js/content/lazy_template_call.js | 44 + addons/website/static/src/js/content/share.js | 87 - .../src/js/content/snippets.animation.js | 827 +++++-- .../static/src/js/content/website_root.js | 232 ++ .../website/static/src/js/content/zoomodoo.js | 503 ++-- addons/website/static/src/js/editor/editor.js | 156 +- .../static/src/js/editor/rte.summernote.js | 59 + .../static/src/js/editor/snippets.options.js | 1118 ++++++--- addons/website/static/src/js/menu/content.js | 616 +++-- .../website/static/src/js/menu/customize.js | 181 ++ addons/website/static/src/js/menu/edit.js | 102 + .../website/static/src/js/menu/mobile_view.js | 60 +- addons/website/static/src/js/menu/navbar.js | 180 ++ .../website/static/src/js/menu/new_content.js | 81 + addons/website/static/src/js/menu/planner.js | 67 +- addons/website/static/src/js/menu/seo.js | 156 +- .../website/static/src/js/menu/translate.js | 123 +- addons/website/static/src/js/tours/banner.js | 141 +- addons/website/static/src/js/tours/rte.js | 297 ++- addons/website/static/src/js/utils.js | 138 ++ addons/website/static/src/js/website.js | 297 +-- .../static/src/js/website.snippets.gallery.js | 377 --- addons/website/static/src/js/widgets/ace.js | 135 +- addons/website/static/src/js/widgets/theme.js | 203 +- .../src/less/website.ui.components.less | 143 +- .../static/src/xml/website.contentMenu.xml | 1 + .../static/src/xml/website.gallery.xml | 70 +- addons/website/static/src/xml/website.seo.xml | 13 - .../static/src/xml/website.translator.xml | 52 - addons/website/static/src/xml/website.xml | 158 +- addons/website/tests/test_ui.py | 10 +- addons/website/views/snippets.xml | 46 +- .../views/website_navbar_templates.xml | 38 +- addons/website/views/website_templates.xml | 59 +- .../static/src/js/website.tour.blog.js | 2 +- .../static/src/js/website_blog.editor.js | 320 +-- addons/website_blog/views/snippets.xml | 10 +- .../views/website_blog_templates.xml | 2 +- .../static/src/js/crm_partner_assign.js | 4 +- addons/website_event/controllers/main.py | 8 +- .../static/src/js/website_event.editor.js | 37 +- .../static/src/js/website_event.js | 44 +- .../static/src/js/website_geolocation.js | 26 +- .../website_event/views/event_templates.xml | 4 +- .../static/src/js/website_event_questions.js | 15 +- .../static/src/js/website_form.js | 74 +- addons/website_forum/controllers/main.py | 4 +- addons/website_forum/data/forum_demo.xml | 2 +- .../static/src/js/website_forum.editor.js | 37 +- .../static/src/js/website_forum.js | 57 +- .../static/src/js/website_forum.share.js | 87 +- addons/website_forum/views/website_forum.xml | 2 +- .../static/src/js/website_gengo.js | 77 +- .../static/src/xml/website.gengo.xml | 14 +- .../website_hr_recruitment_templates.xml | 2 +- .../static/src/js/website_links.js | 149 +- .../static/src/js/website_links_charts.js | 66 +- .../src/js/website_links_code_editor.js | 30 +- addons/website_mail/static/src/js/follow.js | 43 +- .../static/src/js/website_mail.js | 456 ++-- .../src/js/website_mail_channel.editor.js | 18 +- .../src/js/website_mail_channel.snippet.js | 70 +- .../website_mail_channel/views/snippets.xml | 2 +- .../src/js/website_mass_mailing.editor.js | 32 +- .../static/src/js/website_mass_mailing.js | 78 +- .../views/snippets_templates.xml | 4 +- .../static/src/js/website_quotation.js | 33 +- addons/website_sale/controllers/main.py | 5 +- .../static/src/js/website_sale.editor.js | 127 +- .../static/src/js/website_sale.js | 13 +- .../static/src/js/website_sale_backend.js | 8 +- .../static/src/js/website_sale_rating.js | 5 +- .../static/src/js/website_sale_tour_shop.js | 2 +- addons/website_sale/views/snippets.xml | 4 +- addons/website_sale/views/templates.xml | 2 +- .../static/src/js/website_sale.js | 8 +- .../static/src/js/website_sale.test.js | 4 +- .../static/src/js/slides_upload.js | 28 +- .../src/js/website.twitter.animation.js | 50 +- .../static/src/js/website.twitter.editor.js | 2 +- doc/howtos/themes.rst | 42 +- 122 files changed, 9879 insertions(+), 7913 deletions(-) create mode 100644 addons/web_editor/static/src/js/content/body_manager.js create mode 100644 addons/web_editor/static/src/js/root_widget.js delete mode 100644 addons/web_editor/static/src/js/snippets.animation.js create mode 100644 addons/web_editor/static/src/xml/translator.xml create mode 100644 addons/website/static/src/js/content/compatibility.js create mode 100644 addons/website/static/src/js/content/lazy_template_call.js delete mode 100644 addons/website/static/src/js/content/share.js create mode 100644 addons/website/static/src/js/content/website_root.js create mode 100644 addons/website/static/src/js/editor/rte.summernote.js create mode 100644 addons/website/static/src/js/menu/customize.js create mode 100644 addons/website/static/src/js/menu/edit.js create mode 100644 addons/website/static/src/js/menu/navbar.js create mode 100644 addons/website/static/src/js/menu/new_content.js create mode 100644 addons/website/static/src/js/utils.js delete mode 100644 addons/website/static/src/js/website.snippets.gallery.js delete mode 100644 addons/website/static/src/xml/website.translator.xml 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($("