diff options
| author | Robot Odoo <xmo+roboto@odoo.com> | 2019-01-17 19:17:22 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-01-17 19:17:22 +0100 |
| commit | 22efdfbecc1e8c688000be98903a5a355d1ece8a (patch) | |
| tree | e097625171b2b67ed5f5288ced67446608b9f633 | |
| parent | f50fbc6d168f0d17fa1e932b4ecc968a92b02b65 (diff) | |
| parent | eb0ea8efbe932c140302e9f8da33a839bcd73146 (diff) | |
[IMP] web_editor,*: Refactoring the wysiwyg editor and 'html' field
_Task #1878502_
- The refactoring allows for the update of the _Summernote_ library.
- A new structure was created by transforming all the plugins in the library using the odoo inheritance system. Plugins are easier to implement with the `AbstractPlugin` to add Odoo behaviors.
- The cleanup allowed us to remove some of the code that was in `web_editor` but only used in `website` or in `mass_mailing`. Some parts are still in `web_editor` but will be moved at a later time.
- The refactoring also had to be done in order to create a set of unit tests. Each behavior can be tested, including the behaviors performed as a consequence of keyboard interactions (for instance: Enter, Tab...).
- From now on, the methods of the library (in this case _Summernote_) can no longer be called by other modules or files. Only the `wysiwyg` widgets can access it, to simplify the updating process. The `wysiwyg` object serves as an interface. A lot of tests are done, ensuring the consistency of outside behavior.
- Depending on the options the snippets will be loaded or not, the editor will be in an iframe or not... all of this is transparent from the outside.
- Regarding iframes, all controllers related to editing have been removed: the new API no longer needs them. This speeds up loading, eases testing and removes complexity for the same features.
**PUBLIC FEATURES**
There are several public methods on the `Wysiwyg` class:
- `Wysiwyg.prepare (WidgetParent)` which returns a deferred resolved when the library (xml, lazy, assets...) is loaded.
- `Wysiwyg.getRange (DOM)` returns the range (selection in the dom)
- `Wysiwyg.setRange (startNode, startOffset, endNode, endOffset)` which creates a range (selection in the dom)
- `Wysiwyg.setRangeFromNode (DOM, options)` that creates a range from an element (option available to select all, start or end)
A jQuery selector was added: `:o_editable`, which indicates whether the current element is editable. That is, if it is contained in a tag with the attribute `contentEditable = "true"` or in a tag with the class `o_editable`.
Several methods are also present:
- `focusIn`: makes a focus and places the cursor at the beginning of the element
- `focusInEnd`: makes a focus and places the cursor at the end of the element
- `selectContent`: makes a focus and selects the content
**HTML FIELD**
The HTML field can receive different options:
- `style-inline:` {boolean} transforms a class into an inline style when saving and vice versa when reading.
- `no-attachment`: {boolean} prevents the use of attachments (in media dialog)
- `cssEdit`: {xml_id} to use a template containing the css to loaded in an iframe when editing
- `cssReadonly`: {xml_id} to use a template containing the css to load into an iframe when viewing in readonly
- `snippets`: {xml_id} snippets template (can be used with or without `cssEdit`)
- `wrapper`: {template} qweb static template (containing a tag: `id = "wrapper"`) that will include the content during editing (removed on save)
**MASS MAILING**
A widget was created for `mass mailing`. There are now two fields: `body_html` and `body_arch`.
`body_arch` contains the code with the class without conversion into inline style, useful when editing and one with the inline style that is visible in readonly mode and sent by email.
Advantage: no spreading errors, able to update css/theme, able to do more changes when converting to inline style so that a maximum of mail clients have an impeccable rendering.
**REMAINING KNOW ISSUES**
These will be fixed in a subsequent commit.
- A few committed changes made during the development of this refactoring were lost in the process and should be reapplied.
- Some minor (mostly range) issues remain with Firefox.
--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr
closes odoo/odoo#29775
223 files changed, 35937 insertions, 19994 deletions
diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index 4370ffb0fd4..9b404e056ea 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -368,7 +368,7 @@ var Discuss = AbstractAction.extend({ */ destroy: function () { if (this.$buttons) { - this.$buttons.off().destroy(); + this.$buttons.off().remove(); } this._super.apply(this, arguments); }, diff --git a/addons/mass_mailing/__manifest__.py b/addons/mass_mailing/__manifest__.py index b0c6daec3bd..70c83a5bb64 100644 --- a/addons/mass_mailing/__manifest__.py +++ b/addons/mass_mailing/__manifest__.py @@ -44,5 +44,8 @@ 'demo': [ 'data/mass_mailing_demo.xml', ], + 'qweb': [ + 'static/src/xml/*.xml', + ], 'application': True, } diff --git a/addons/mass_mailing/controllers/__init__.py b/addons/mass_mailing/controllers/__init__.py index 83d13fa601d..5d4b25db9c0 100644 --- a/addons/mass_mailing/controllers/__init__.py +++ b/addons/mass_mailing/controllers/__init__.py @@ -2,4 +2,3 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main -from . import web_editor diff --git a/addons/mass_mailing/controllers/web_editor.py b/addons/mass_mailing/controllers/web_editor.py deleted file mode 100644 index 88e9f768690..00000000000 --- a/addons/mass_mailing/controllers/web_editor.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import http -from odoo.http import request -from odoo.addons.web_editor.controllers.main import Web_Editor - - -class Web_Editor(Web_Editor): - - @http.route(["/website_mass_mailing/field/popup_content"], type='http', auth="user") - def mass_mailing_FieldTextHtmlPopupTemplate(self, model=None, res_id=None, field=None, callback=None, **kwargs): - kwargs['snippets'] = '/website/snippets' - kwargs['template'] = 'mass_mailing.FieldTextHtmlPopupContent' - return self.FieldTextHtml(model, res_id, field, callback, **kwargs) - - @http.route('/mass_mailing/field/email_template', type='http', auth="user") - def mass_mailing_FieldTextHtmlEmailTemplate(self, model=None, res_id=None, field=None, callback=None, **kwargs): - kwargs['snippets'] = '/mass_mailing/snippets' - kwargs['template'] = 'mass_mailing.FieldTextHtmlInline' - return self.FieldTextHtmlInline(model, res_id, field, callback, **kwargs) - - @http.route(['/mass_mailing/snippets'], type='json', auth="user", website=True) - def mass_mailing_snippets(self): - values = {'company_id': request.env['res.users'].browse(request.uid).company_id} - return request.env.ref('mass_mailing.email_designer_snippets').render(values) diff --git a/addons/mass_mailing/data/mass_mailing_demo.xml b/addons/mass_mailing/data/mass_mailing_demo.xml index b8e403a1a6c..8819dd20202 100644 --- a/addons/mass_mailing/data/mass_mailing_demo.xml +++ b/addons/mass_mailing/data/mass_mailing_demo.xml @@ -85,65 +85,115 @@ <field name="reply_to_mode">email</field> <field name="reply_to">Info <info@yourcompany.example.com></field> <field name="body_html" type="html"> -<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center"> -<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 24px; background-color: white; color: #454748; border-collapse:separate;"> -<tbody> - <!-- HEADER --> - <tr> - <td align="center" style="min-width: 590px;"> - <table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: white; padding: 0; border-collapse:separate;"> - <tr><td valign="middle"> - <span style="font-size: 20px; font-weight: bold;">Your Company</span> - </td><td valign="middle" align="right"> - <img src="/logo.png" style="padding: 0px; margin: 0px; height: 48px;" alt="YourCompanyny"/> - </td></tr> - <tr><td colspan="2" style="text-align:center;"> - <hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin:4px 0px 32px 0px;"/> - </td></tr> - </table> - </td> - </tr> - <!-- CONTENT --> - <tr> - <td style="min-width: 590px;"> - <div style="margin: 0px; padding: 0px;"> - <p style="margin: 0px; padding: 0px; font-size: 13px;"> - Great stories have personality. Consider telling a great story that provides personality. - Writing a story with personality for potential clients will assist with making a relationship connection. - This shows up in small quirks like word choices or phrases. Write from your point of view, not from someone else's experience.<br /> - Great stories are for everyone even when only written for just one person. - If you try to write with a wide general audience in mind, your story will ring false and be bland. - No one will be interested. Write for one person. If it’s genuine for the one, it’s genuine for the rest. - </p> - <p style="margin: 32px 0px 32px 0px; text-align: center;"> - <a href="http://www.example.com" - style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;"> - Click here for your special promotion ! - </a> - </p> - </div> - </td> - </tr> - <!-- FOOTER --> - <tr> - <td align="center" style="min-width: 590px; padding: 0 8px 0 8px; font-size:11px;"> - <hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 4px 0px;"/> - <b>YourCompany</b><br/> - <div style="color: #999999;"> - <a href="/unsubscribe_from_list" style="text-decoration:none; color: #999999;">Unsubscribe</a> - | - <a href="http://www.example.net/page/contactus" style="text-decoration:none; color: #999999;">Contact</a> - </div> - </td> - </tr> -</tbody> -</table> -</td></tr> -<!-- POWERED BY --> -<tr><td align="center" style="min-width: 590px;"> - Powered by <a target="_blank" href="https://www.odoo.com" style="color: #875A7B;">Odoo</a> -</td></tr> -</table> +<div class="o_layout o_default_theme"> + <table class="o_mailWrapper" style="border-collapse:collapse;"> + <tbody> + <tr> + <td class="o_mail_no_resize o_not_editable" style="text-align:left;"> </td> + <td class="o_mail_no_options o_mailWrapper_td oe_structure" style="text-align:left;width:100%;"> + <div class="o_mail_block_header_logo"> + <div class="o_mail_snippet_general" style="margin:0px auto 0px auto;background-color:rgb(255, 255, 255);max-width:600px;width:100%;"> + <table align="center" cellspacing="0" cellpadding="0" border="0" class="o_mail_table_styles o_mail_h_padding" style="padding:0 20px 0 20px;width:100%;border-collapse:separate;"> + <tbody> + <tr> + <td valign="center" width="30%" class="text-center o_mail_v_padding pb0" style="padding:20px 0 0px 0;vertical-align:middle;text-align:center;"> + <a href="http://www.example.com" style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);"> + <img border="0" src="/mass_mailing/static/src/img/theme_default/s_default_image_logo.png" style="border-style:none;height:auto;vertical-align:middle;max-width:400px;width:auto" alt="Your Logo"/> ​ + </a> + </td> + </tr> + </tbody> + </table> + </div> + </div> + <div class="o_mail_block_footer_separator" style="margin:0 20px 0 20px;"> + <div class="o_mail_snippet_general" style="margin:0px auto 0px auto;background-color:rgb(255, 255, 255);max-width:600px;width:100%;"> + <table align="center" cellspacing="0" cellpadding="0" class="o_mail_table_styles o_mail_full_width_padding" style="width:100%;border-collapse:separate;"> + <tbody> + <tr> + <td valign="top" style="padding:20px 0 20px 0;text-align:left;vertical-align:top;width:100%;" class="o_mail_v_padding o_mail_no_colorpicker"> + <div style="background-color:rgb(245, 245, 245);height:2px;width:100%;" class="separator"></div> + </td> + </tr> + </tbody> + </table> + </div> + </div> + <div class="o_mail_block_paragraph"> + <div class="o_mail_snippet_general" style="margin:0px auto 0px auto;background-color:rgb(255, 255, 255);max-width:600px;width:100%;"> + <table align="center" cellspacing="0" cellpadding="0" border="0" class="o_mail_table_styles" style="width:100%;border-collapse:separate;"> + <tbody> + <tr> + <td width="100%" class="o_mail_h_padding o_mail_v_padding o_mail_no_colorpicker" style="padding:20px;text-align:left;vertical-align:top;"> + <p style="margin:0px 0 1rem 0;font-size:14px;"> + Great stories have personality. Consider telling a great story that provides personality. Writing a story with personality for potential clients will assist with making a relationship connection. This shows up in small quirks like word choices or phrases. Write from your point of view, not from someone else's experience. + <br/>Great stories are for everyone even when only written for just one person. If you try to write with a wide general audience in mind, your story will ring false and be bland. No one will be interested. Write for one person. If it’s genuine for the one, it’s genuine for the rest. + </p> + </td> + </tr> + </tbody> + </table> + </div> + </div> + <div class="o_mail_block_footer_social o_mail_footer_social_center"> + <div class="o_mail_snippet_general" style="margin:0px auto 0px auto;background-color:rgb(255, 255, 255);max-width:600px;width:100%;"> + <table align="center" cellspacing="0" cellpadding="0" class="o_mail_table_styles o_mail_full_width_padding" style="border-style:solid none none none;padding:20px 0 20px 0;border-top-color:rgb(245, 245, 245);border-top-width:2px;width:100%;border-collapse:separate;"> + <tbody> + <tr> + <td class="o_mail_footer_social" style="text-align:center;vertical-align:middle;"> + <a title="Facebook" href="https://www.facebook.com/Odoo" style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);"> + <img src="/web_editor/font_to_img/61594/rgb(100,89,116)/16" data-class="fa fa-facebook" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/> ​ + + </a> + <a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="Google Plus" href="https://plus.google.com/+Odooapps"> + <img src="/web_editor/font_to_img/61653/rgb(100,89,116)/16" data-class="fa fa-google-plus" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/> + </a> + <a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="LinkedIn" href="https://www.linkedin.com/company/odoo"> + <img src="/web_editor/font_to_img/61665/rgb(100,89,116)/16" data-class="fa fa-linkedin" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/> + </a> + <a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="Twitter" href="https://twitter.com/Odoo"> + <img src="/web_editor/font_to_img/61593/rgb(100,89,116)/16" data-class="fa fa-twitter" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/> + </a> + <a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="Instagram" href="https://www.instagram.com/explore/tags/odoo/"> + <img src="/web_editor/font_to_img/61805/rgb(100,89,116)/16" data-class="fa fa-instagram" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/> ​ + + </a> + </td> + </tr> + <tr> + <td class="o_mail_footer_links o_default_snippet_text" style="padding:10px 0 10px 0;text-align:center;vertical-align:middle;"> + <a href="/unsubscribe_from_list" class="btn btn-link o_default_snippet_text" style="text-decoration:none;border-radius:0.25rem;border-style:solid;padding:0px;cursor:pointer;line-height:1.5;font-size:12px;border-left-color:transparent;border-bottom-color:transparent;border-right-color:transparent;border-top-color:transparent;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;user-select:none;vertical-align:middle;white-space:nowrap;text-align:center;font-weight:bold;display:inline-block;background-color:transparent;color:rgb(100, 89, 116);">Unsubscribe</a> | + + <a href="/contactus" class="btn btn-link o_default_snippet_text" style="text-decoration:none;border-radius:0.25rem;border-style:solid;padding:0px;cursor:pointer;line-height:1.5;font-size:12px;border-left-color:transparent;border-bottom-color:transparent;border-right-color:transparent;border-top-color:transparent;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;user-select:none;vertical-align:middle;white-space:nowrap;text-align:center;font-weight:bold;display:inline-block;background-color:transparent;color:rgb(100, 89, 116);">Contact</a> + </td> + </tr> + <tr> + <td style="text-align:left;vertical-align:middle;"> + <p class="o_mail_footer_copy" style="margin:0px 0 1rem 0;text-align:center;font-weight:bold;color:rgb(147, 146, 146);font-size:9px;"> + <img src="/web_editor/font_to_img/61945/rgb(147,146,146)/9" data-class="fa fa-copyright" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/>2018 All Rights Reserved + </p> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </td> + </tr> + </tbody> + </table> + <table align="center" cellspacing="0" cellpadding="0" class="o_mail_table_styles o_mail_full_width_padding" style="width:100%;border-collapse:separate;"> + <tbody> + <tr> + <td align="center" style="padding:16px 0 16px 0;" class="pt16 pb16"> + Powered by + + <a target="_blank" href="https://www.odoo.com?utm_source=db&utm_medium=email" style="text-decoration:none;background-color:transparent;color:#875A7B;">Odoo</a> + </td> + </tr> + </tbody> + </table> +</div> </field> <field name="attachment_ids" eval="[(4, ref('mass_mail_attach_1'))]"/> </record> diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index f9d95c893d9..a5121e3d773 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -456,7 +456,9 @@ class MassMailing(models.Model): default=lambda self: self.env['mail.message']._get_default_from()) sent_date = fields.Datetime(string='Sent Date', oldname='date', copy=False) schedule_date = fields.Datetime(string='Schedule in the Future') - body_html = fields.Html(string='Body', sanitize_attributes=False) + # don't translate 'body_arch', the translations are only on 'body_html' + body_arch = fields.Html(string='Body', translate=False) + body_html = fields.Html(string='Body converted to be send by mail', sanitize_attributes=False) attachment_ids = fields.Many2many('ir.attachment', 'mass_mailing_ir_attachments_rel', 'mass_mailing_id', 'attachment_id', string='Attachments') keep_archives = fields.Boolean(string='Keep Archives') @@ -621,7 +623,6 @@ class MassMailing(models.Model): else: mailing_domain.append((0, '=', 1)) self.mailing_domain = repr(mailing_domain) - self.body_html = "on_change_model_and_list" @api.onchange('subject') def _onchange_subject(self): diff --git a/addons/mass_mailing/static/src/js/mass_mailing.js b/addons/mass_mailing/static/src/js/mass_mailing.js new file mode 100644 index 00000000000..404ed6e554b --- /dev/null +++ b/addons/mass_mailing/static/src/js/mass_mailing.js @@ -0,0 +1,37 @@ +odoo.define('mass_mailing.mass_mailing', function (require) { +"use strict"; + +var KanbanRecord = require('web.KanbanRecord'); +var KanbanColumn = require('web.KanbanColumn'); + +KanbanRecord.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _openRecord: function () { + if (this.modelName === 'mail.mass_mailing.campaign') { + this.$('.oe_mailings').click(); + } else if (this.modelName === 'mail.mass_mailing.list' && + this.$('.o_mailing_list_kanban_boxes a')) { + this.$('.o_mailing_list_kanban_boxes a').first().click(); + } else { + this._super.apply(this, arguments); + } + }, +}); + +KanbanColumn.include({ + init: function () { + this._super.apply(this, arguments); + if (this.modelName === 'mail.mass_mailing') { + this.draggable = false; + } + }, +}); + +}); diff --git a/addons/mass_mailing/static/src/js/mass_mailing_editor.js b/addons/mass_mailing/static/src/js/mass_mailing_editor.js deleted file mode 100644 index c7e95198005..00000000000 --- a/addons/mass_mailing/static/src/js/mass_mailing_editor.js +++ /dev/null @@ -1,434 +0,0 @@ -odoo.define('mass_mailing.editor', function (require) { -"use strict"; - -require('web.dom_ready'); -var ajax = require('web.ajax'); -var core = require('web.core'); -var rte = require('web_editor.rte'); -var options = require('web_editor.snippets.options'); -var snippets_editor = require('web_editor.snippet.editor'); - -var $editable_area = $('#editable_area'); -var odoo_top = window.top.odoo; - -// Snippet option for resizing image and column width inline like excel -options.registry.sizing_x = options.Class.extend({ - /** - * @override - */ - start: function () { - var def = this._super.apply(this, arguments); - - this.containerWidth = this.$target.parent().closest("td, table, div").width(); - - var self = this; - var offset, sib_offset, target_width, sib_width; - - this.$overlay.find(".o_handle.e, .o_handle.w").removeClass("readonly"); - this.isIMG = this.$target.is("img"); - if (this.isIMG) { - this.$overlay.find(".o_handle.w").addClass("readonly"); - this.$overlay.find(".oe_snippet_move, .oe_snippet_clone").addClass('d-none'); - } - - var $body = $(document.body); - this.$overlay.find(".o_handle").on('mousedown', function (event) { - event.preventDefault(); - var $handle = $(this); - var compass = false; - - _.each(['n', 's', 'e', 'w'], function (handler) { - if ($handle.hasClass(handler)) { compass = handler; } - }); - if (self.isIMG) { compass = "image"; } - - $body.on("mousemove.mass_mailing_width_x", function (event) { - event.preventDefault(); - offset = self.$target.offset().left; - target_width = self.get_max_width(self.$target); - if (compass === 'e' && self.$target.next().offset()) { - sib_width = self.get_max_width(self.$target.next()); - sib_offset = self.$target.next().offset().left; - self.change_width(event, self.$target, target_width, offset, true); - self.change_width(event, self.$target.next(), sib_width, sib_offset, false); - } - if (compass === 'w' && self.$target.prev().offset()) { - sib_width = self.get_max_width(self.$target.prev()); - sib_offset = self.$target.prev().offset().left; - self.change_width(event, self.$target, target_width, offset, false); - self.change_width(event, self.$target.prev(), sib_width, sib_offset, true); - } - if (compass === 'image') { - self.change_width(event, self.$target, target_width, offset, true); - } - }); - $body.one("mouseup", function () { - $body.off('.mass_mailing_width_x'); - }); - }); - - return def; - }, - change_width: function (event, target, target_width, offset, grow) { - target.css("width", grow ? (event.pageX - offset) : (offset + target_width - event.pageX)); - this.trigger_up('cover_update'); - }, - get_int_width: function (el) { - return parseInt($(el).css("width"), 10); - }, - get_max_width: function ($el) { - return this.containerWidth - _.reduce(_.map($el.siblings(), this.get_int_width), function (memo, w) { return memo + w; }); - }, - onFocus: function () { - this._super.apply(this, arguments); - - if (this.$target.is("td, th")) { - this.$overlay.find(".o_handle.e, .o_handle.w").toggleClass("readonly", this.$target.siblings().length === 0); - } - }, -}); - -options.registry.table_item = options.Class.extend({ - onClone: function (options) { - this._super.apply(this, arguments); - - // If we cloned a td or th element... - if (options.isCurrent && 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 = 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 = 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 = 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($("<td/>", {style: "width: " + width + "px;"})); - }); - } - }, - onRemove: function () { - this._super.apply(this, arguments); - - // If we are removing a td or th element which was not alone on its row ... - if (this.$target.is("td, th") && this.$target.siblings().length > 0) { - var $trs = this.$target.closest("table").children("thead, tbody, tfoot").addBack().children("tr").not(this.$target.parent()); - if ($trs.length) { // ... if there are other rows in the table ... - var $last_tds = $trs.children(":last-child"); - if (_.reduce($last_tds, function (memo, td) { return memo + (td.innerHTML || ""); }, "").trim() === "") { - $last_tds.remove(); // ... remove the potential full empty column in the table - } else { - this.$target.parent().append("<td/>"); // ... else, if there is no full empty column, append an empty col in the current row - } - } - } - }, -}); - -var fn_popover_update = $.summernote.eventHandler.modules.popover.update; -$.summernote.eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) { - fn_popover_update.call(this, $popover, oStyle, isAirMode); - $("span.o_table_handler, div.note-table").remove(); -}; - -ajax.loadXML("/mass_mailing/static/src/xml/mass_mailing.xml", core.qweb); - -snippets_editor.Class.include({ - _computeSnippetTemplates: function (html) { - var self = this; - var ret = this._super.apply(this, arguments); - - var $themes = this.$("#email_designer_themes").children(); - if ($themes.length === 0) return ret; - - /** - * Initialize theme parameters. - */ - var all_classes = ""; - var themes_params = _.map($themes, function (theme) { - var $theme = $(theme); - var name = $theme.data("name"); - var classname = "o_" + name + "_theme"; - all_classes += " " + classname; - var images_info = _.defaults($theme.data("imagesInfo") || {}, {all: {}}); - _.each(images_info, function (info) { - info = _.defaults(info, images_info.all, {module: "mass_mailing", format: "jpg"}); - }); - return { - name: name, - className: classname || "", - img: $theme.data("img") || "", - template: $theme.html().trim(), - nowrap: !!$theme.data('nowrap'), - get_image_info: function (filename) { - if (images_info[filename]) { - return images_info[filename]; - } - return images_info.all; - } - }; - }); - $themes.parent().remove(); - - var $body = $(document.body); - var $snippets = this.$(".oe_snippet"); - var $snippets_menu = this.$el.find("#snippets_menu"); - - /** - * Create theme selection screen and check if it must be forced opened. - * Reforce it opened if the last snippet is removed. - */ - var $dropdown = $(core.qweb.render("mass_mailing.theme_selector", { - themes: themes_params - })); - var first_choice; - check_if_must_force_theme_choice(); - - /** - * Add proposition to install enterprise themes if not installed. - */ - var $mail_themes_upgrade = $dropdown.find(".o_mass_mailing_themes_upgrade"); - $mail_themes_upgrade.on("click", function (e) { - e.stopImmediatePropagation(); - e.preventDefault(); - odoo_top[window.callback+"_do_action"]("mass_mailing.action_mass_mailing_configuration"); - }); - - /** - * Switch theme when a theme button is hovered. Confirm change if the theme button - * is pressed. - */ - var selected_theme = false; - $dropdown.on("mouseenter", ".dropdown-item", function (e) { - if (first_choice) return; - e.preventDefault(); - var theme_params = themes_params[$(e.currentTarget).index()]; - switch_theme(theme_params); - }); - $dropdown.on("click", ".dropdown-item", function (e) { - e.preventDefault(); - var theme_params = themes_params[$(e.currentTarget).index()]; - if (first_choice) { - switch_theme(theme_params); - $body.removeClass("o_force_mail_theme_choice"); - first_choice = false; - - if ($mail_themes_upgrade.length) { - $dropdown.remove(); - $snippets_menu.empty(); - } - } - - switch_images(theme_params, $snippets); - - selected_theme = theme_params; - - // Notify form view - odoo_top[window.callback+"_downup"]($editable_area.addClass("o_dirty").html()); - }); - - /** - * If the user opens the theme selection screen, indicates which one is active and - * saves the information... - * ... then when the user closes check if the user confirmed its choice and restore - * previous state if this is not the case. - */ - $dropdown.on("shown.bs.dropdown", function () { - check_selected_theme(); - $dropdown.find(".dropdown-item").removeClass("selected").filter(function () { - return ($(this).has(".o_thumb[style=\""+ "background-image: url(" + (selected_theme && selected_theme.img) + "_small.png)"+ "\"]").length > 0); - }).addClass("selected"); - }); - $dropdown.on("hidden.bs.dropdown", function () { - switch_theme(selected_theme); - }); - - /** - * On page load, check the selected theme and force switching to it (body needs the - * theme style for its edition toolbar). - */ - check_selected_theme(); - $body.addClass(selected_theme.className); - switch_images(selected_theme, $snippets); - - $dropdown.insertAfter($snippets_menu); - - return ret; - - function check_if_must_force_theme_choice() { - first_choice = editable_area_is_empty(); - $body.toggleClass("o_force_mail_theme_choice", first_choice); - } - - function editable_area_is_empty($layout) { - $layout = $layout || $editable_area.find(".o_layout"); - var $mail_wrapper = $layout.children(".o_mail_wrapper"); - var $mail_wrapper_content = $mail_wrapper.find('.o_mail_wrapper_td'); - if (!$mail_wrapper_content.length) { // compatibility - $mail_wrapper_content = $mail_wrapper; - } - return ( - $editable_area.html().trim() === "" - || ($layout.length > 0 && ($layout.html().trim() === "" || $mail_wrapper_content.length > 0 && $mail_wrapper_content.html().trim() === "")) - ); - } - - function check_selected_theme() { - var $layout = $editable_area.find(".o_layout"); - if ($layout.length === 0) { - selected_theme = false; - } else { - _.each(themes_params, function (theme_params) { - if ($layout.hasClass(theme_params.className)) { - selected_theme = theme_params; - } - }); - } - } - - function switch_images(theme_params, $container) { - if (!theme_params) return; - $container.find("img").each(function () { - var $img = $(this); - var src = $img.attr("src"); - - var m = src.match(/^\/web\/image\/\w+\.s_default_image_(?:theme_[a-z]+_)?(.+)$/); - if (!m) { - m = src.match(/^\/\w+\/static\/src\/img\/(?:theme_[a-z]+\/)?s_default_image_(.+)\.[a-z]+$/); - } - if (!m) return; - - var file = m[1]; - var img_info = theme_params.get_image_info(file); - - if (img_info.format) { - src = "/" + img_info.module + "/static/src/img/theme_" + theme_params.name + "/s_default_image_" + file + "." + img_info.format; - } else { - src = "/web/image/" + img_info.module + ".s_default_image_theme_" + theme_params.name + "_" + file; - } - - $img.attr("src", src); - }); - } - - function switch_theme(theme_params) { - if (!theme_params || switch_theme.last === theme_params) return; - switch_theme.last = theme_params; - - $body.removeClass(all_classes).addClass(theme_params.className); - - var $old_layout = $editable_area.find('.o_layout'); - - var $new_wrapper; - var $new_wrapper_content; - if (theme_params.nowrap) { - $new_wrapper = $('<div/>', {class: 'oe_structure'}); - $new_wrapper_content = $new_wrapper; - } else { - // This wrapper structure is the only way to have a responsive - // and centered fixed-width content column on all mail clients - $new_wrapper = $('<table/>', {class: 'o_mail_wrapper'}); - $new_wrapper_content = $('<td/>', {class: 'o_mail_no_options o_mail_wrapper_td oe_structure'}); - $new_wrapper.append($('<tr/>').append( - $('<td/>', {class: 'o_mail_no_resize o_not_editable', contenteditable: 'false'}), - $new_wrapper_content, - $('<td/>', {class: 'o_mail_no_resize o_not_editable', contenteditable: 'false'}) - )); - } - var $new_layout = $('<div/>', {class: 'o_layout ' + theme_params.className}).append($new_wrapper); - - var $contents; - if (first_choice) { - $contents = theme_params.template; - } else if ($old_layout.length) { - $contents = ($old_layout.hasClass('oe_structure') ? $old_layout : $old_layout.find('.oe_structure').first()).contents(); - } else { - $contents = $editable_area.contents(); - } - - $new_wrapper_content.append($contents); - switch_images(theme_params, $new_wrapper_content); - $editable_area.empty().append($new_layout); - $old_layout.remove(); - - if (first_choice) { - self._registerDefaultTexts($new_wrapper_content); - if(theme_params.name == 'basic') { - $editable_area.focusIn(); - } - } - self._disableUndroppableSnippets(); - } - }, - cleanForSave: function () { - this._super.apply(this, arguments); - // remove font-family from all elements for plain text theme (just like gmail) - var $basicTheme = this.$editable.find('.o_basic_theme'); - if($basicTheme.length && this.$editable.data('oe-model') === 'mail.mass_mailing') { - this.$editable.find('*').css('font-family', ''); - } - } -}); - -var callback = window ? window["callback"] : undefined; -odoo_top[callback+"_updown"] = function (value, fields_values, field_name) { - if (!window || window.closed) { - delete odoo_top[callback+"_updown"]; - return; - } - - var $editable = $("#editable_area"); - var _val = $editable.prop("innerHTML"); - var editor_enable = $('body').hasClass('editor_enable'); - value = value || ""; - - if (value !==_val) { - if (editor_enable) { - if (value !== fields_values[field_name]) { - rte.history.recordUndo($editable); - } - core.bus.trigger('deactivate_snippet'); - } - - if (value.indexOf('on_change_model_and_list') === -1) { - $editable.html(value); - - if (editor_enable) { - if (value !== fields_values[field_name]) { - $editable.trigger("content_changed"); - } - } - } - } - - if (fields_values.mailing_model && editor_enable) { - if (value.indexOf('on_change_model_and_list') !== -1) { - odoo_top[callback+"_downup"](_val); - } - } -}; - -if ($editable_area.html().indexOf('on_change_model_and_list') !== -1) { - $editable_area.empty(); -} -// Adding compatibility for the outlook compliance of mailings. -// Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42 -options.registry.background.include({ - start: function () { - this._super(); - var $table_target = this.$target.find('table:first'); - if ($table_target) { - this.$target = $table_target; - } - } -}); -}); diff --git a/addons/mass_mailing/static/src/js/mass_mailing_field_text_html.js b/addons/mass_mailing/static/src/js/mass_mailing_field_text_html.js deleted file mode 100644 index a14b9588613..00000000000 --- a/addons/mass_mailing/static/src/js/mass_mailing_field_text_html.js +++ /dev/null @@ -1,29 +0,0 @@ -odoo.define('mass_mailing.field_text_html', function (require) { - -var FieldTextHtml = require('web_editor.backend').FieldTextHtml; -var fieldRegistry = require('web.field_registry'); - -var MassMailingFieldTextHtml = FieldTextHtml.extend({ - /** - * The html_frame widget is opened in an iFrame that has its URL encoded - * with all the key/values returned by this method. - * - * Some fields can get very long values and we want to omit them for the URL building - * - * @override - */ - getDatarecord: function () { - return _.omit(this._super(), [ - 'mailing_domain', - 'contact_list_ids', - 'body_html', - 'attachment_ids' - ]); - } -}); - -fieldRegistry.add('mass_mailing_html_frame', MassMailingFieldTextHtml); - -return MassMailingFieldTextHtml; - -}); diff --git a/addons/mass_mailing/static/src/js/mass_mailing_snippets.js b/addons/mass_mailing/static/src/js/mass_mailing_snippets.js new file mode 100644 index 00000000000..fe8093b17d9 --- /dev/null +++ b/addons/mass_mailing/static/src/js/mass_mailing_snippets.js @@ -0,0 +1,142 @@ +odoo.define('mass_mailing.snippets.options', function (require) { +"use strict"; + +var options = require('web_editor.snippets.options'); + +// Snippet option for resizing image and column width inline like excel +options.registry.mass_mailing_sizing_x = options.Class.extend({ + /** + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + + this.containerWidth = this.$target.parent().closest("td, table, div").width(); + + var self = this; + var offset, sib_offset, target_width, sib_width; + + this.$overlay.find(".o_handle.e, .o_handle.w").removeClass("readonly"); + this.isIMG = this.$target.is("img"); + if (this.isIMG) { + this.$overlay.find(".o_handle.w").addClass("readonly"); + this.$overlay.find(".oe_snippet_move, .oe_snippet_clone").addClass('d-none'); + } + + var $body = $(this.ownerDocument.body); + this.$overlay.find(".o_handle").on('mousedown', function (event) { + event.preventDefault(); + var $handle = $(this); + var compass = false; + + _.each(['n', 's', 'e', 'w'], function (handler) { + if ($handle.hasClass(handler)) { compass = handler; } + }); + if (self.isIMG) { compass = "image"; } + + $body.on("mousemove.mass_mailing_width_x", function (event) { + event.preventDefault(); + offset = self.$target.offset().left; + target_width = self.get_max_width(self.$target); + if (compass === 'e' && self.$target.next().offset()) { + sib_width = self.get_max_width(self.$target.next()); + sib_offset = self.$target.next().offset().left; + self.change_width(event, self.$target, target_width, offset, true); + self.change_width(event, self.$target.next(), sib_width, sib_offset, false); + } + if (compass === 'w' && self.$target.prev().offset()) { + sib_width = self.get_max_width(self.$target.prev()); + sib_offset = self.$target.prev().offset().left; + self.change_width(event, self.$target, target_width, offset, false); + self.change_width(event, self.$target.prev(), sib_width, sib_offset, true); + } + if (compass === 'image') { + self.change_width(event, self.$target, target_width, offset, true); + } + }); + $body.one("mouseup", function () { + $body.off('.mass_mailing_width_x'); + }); + }); + + return def; + }, + change_width: function (event, target, target_width, offset, grow) { + target.css("width", grow ? (event.pageX - offset) : (offset + target_width - event.pageX)); + this.trigger_up('cover_update'); + }, + get_int_width: function (el) { + return parseInt($(el).css("width"), 10); + }, + get_max_width: function ($el) { + return this.containerWidth - _.reduce(_.map($el.siblings(), this.get_int_width), function (memo, w) { return memo + w; }); + }, + onFocus: function () { + this._super.apply(this, arguments); + + if (this.$target.is("td, th")) { + this.$overlay.find(".o_handle.e, .o_handle.w").toggleClass("readonly", this.$target.siblings().length === 0); + } + }, +}); + +options.registry.mass_mailing_table_item = options.Class.extend({ + onClone: function (options) { + this._super.apply(this, arguments); + + // If we cloned a td or th element... + if (options.isCurrent && 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 = 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 = 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 = 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($("<td/>", {style: "width: " + width + "px;"})); + }); + } + }, + onRemove: function () { + this._super.apply(this, arguments); + + // If we are removing a td or th element which was not alone on its row ... + if (this.$target.is("td, th") && this.$target.siblings().length > 0) { + var $trs = this.$target.closest("table").children("thead, tbody, tfoot").addBack().children("tr").not(this.$target.parent()); + if ($trs.length) { // ... if there are other rows in the table ... + var $last_tds = $trs.children(":last-child"); + if (_.reduce($last_tds, function (memo, td) { return memo + (td.innerHTML || ""); }, "").trim() === "") { + $last_tds.remove(); // ... remove the potential full empty column in the table + } else { + this.$target.parent().append("<td/>"); // ... else, if there is no full empty column, append an empty col in the current row + } + } + } + }, +}); + +// Adding compatibility for the outlook compliance of mailings. +// Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42 +options.registry.background = options.registry.background.extend({ + start: function () { + this._super(); + if (this.snippets && this.snippets.split('.')[0] === "mass_mailing") { + var $table_target = this.$target.find('table:first'); + if ($table_target.length) { + this.$target = $table_target; + } + } + } +}); +}); diff --git a/addons/mass_mailing/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js new file mode 100644 index 00000000000..e633ad2b4a1 --- /dev/null +++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js @@ -0,0 +1,505 @@ +odoo.define('mass_mailing.FieldHtml', function (require) { +'use strict'; + +var config = require('web.config'); +var core = require('web.core'); +var FieldHtml = require('web_editor.field.html'); +var fieldRegistry = require('web.field_registry'); +var convertInline = require('web_editor.convertInline'); + +var _t = core._t; + + +var MassMailingFieldHtml = FieldHtml.extend({ + xmlDependencies: (FieldHtml.prototype.xmlDependencies || []).concat(["/mass_mailing/static/src/xml/mass_mailing.xml"]), + + custom_events: _.extend({}, FieldHtml.prototype.custom_events, { + snippets_loaded: '_onSnippetsLoaded', + }), + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (!this.nodeOptions.snippets) { + this.nodeOptions.snippets = 'mass_mailing.email_designer_snippets'; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Commit the change in 'style-inline' on an other field + * nodeOptions: + * - inline-field: fieldName to save the html value converted into inline code + * + * @override + */ + commitChanges: function () { + var self = this; + var fieldName = this.nodeOptions['inline-field']; + + if (this.$content.find('.o_basic_theme').length) { + this.$content.find('*').css('font-family', ''); + } + + var $editable = this.wysiwyg.getEditable(); + + return this.wysiwyg.save().then(function (isDirty) { + self._isDirty = isDirty; + + convertInline.attachmentThumbnailToLinkImg($editable); + convertInline.fontToImg($editable); + convertInline.classToStyle($editable); + + self.trigger_up('field_changed', { + dataPointID: self.dataPointID, + changes: _.object([fieldName], [self._unWrap($editable.html())]) + }); + + if (self._isDirty && self.mode === 'edit') { + return self._doAction(); + } + }); + }, + /** + * The html_frame widget is opened in an iFrame that has its URL encoded + * with all the key/values returned by this method. + * + * Some fields can get very long values and we want to omit them for the URL building. + * + * @override + */ + getDatarecord: function () { + return _.omit(this._super(), [ + 'mailing_domain', + 'contact_list_ids', + 'body_html', + 'attachment_ids' + ]); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add generateOptions options to change wysiwyg configuration: + * - Remove table menu in toolbar + * - Remove table popover + * - Add rules for isEditableNode, the TD must content a not TABLE node as + * children to be editable. + * + * @override + */ + _getWysiwygOptions: function () { + var options = this._super(); + options.generateOptions = function (options) { + options.toolbar = _.filter(options.toolbar, function (item) { + return item[0] !== 'table'; + }); + delete options.popover.table; + + var isEditableNode = options.isEditableNode; + options.isEditableNode = function (node) { + if (node.tagName === "TD" && !$(node).children('*:not(table):first').length) { + return false; + } + return isEditableNode.call(this, node); + }; + + return options; + }; + return options; + }, + /** + * Returns true if must force the user to choose a theme. + * + * @private + * @returns {Boolean} + */ + _checkIfMustForceThemeChoice: function () { + var firstChoice = this._editableAreaIsEmpty(); + this.$content.closest('body').toggleClass("o_force_mail_theme_choice", firstChoice); + return firstChoice; + }, + /** + * Returns true if the editable area is empty. + * + * @private + * @param {JQuery} [$layout] + * @returns {Boolean} + */ + _editableAreaIsEmpty: function ($layout) { + $layout = $layout || this.$content.find(".o_layout"); + var $mailWrapper = $layout.children(".o_mailWrapper"); + var $mailWrapperContent = $mailWrapper.find('.o_mailWrapper_td'); + if (!$mailWrapperContent.length) { // compatibility + $mailWrapperContent = $mailWrapper; + } + var value; + if ($mailWrapperContent.length > 0) { + value = $mailWrapperContent.html(); + } else if ($layout.length) { + value = $layout.html(); + } else { + value = this.wysiwyg.getValue(); + } + var blankEditable = "<p><br></p>"; + return value === "" || value === blankEditable; + }, + /** + * @override + */ + _renderEdit: function () { + this._isFromInline = !this.value; + if (!this.value) { + this.value = this.recordData[this.nodeOptions['inline-field']]; + } + return this._super.apply(this, arguments); + }, + + /** + * @override + * @returns {JQuery} + */ + _renderTranslateButton: function () { + var fieldName = this.nodeOptions['inline-field']; + if (_t.database.multi_lang && this.record.fields[fieldName].translate && this.res_id) { + return $('<button>', { + type: 'button', + 'class': 'o_field_translate fa fa-globe btn btn-link', + }) + .on('click', this._onTranslate.bind(this)); + } + return $(); + }, + /** + * Returns the selected theme, if any. + * + * @private + * @param {Object} themesParams + * @returns {false|Object} + */ + _getSelectedTheme: function (themesParams) { + var $layout = this.$content.find(".o_layout"); + var selectedTheme = false; + if ($layout.length !== 0) { + _.each(themesParams, function (themeParams) { + if ($layout.hasClass(themeParams.className)) { + selectedTheme = themeParams; + } + }); + } + return selectedTheme; + }, + /** + * Swap the previous theme's default images with the new ones. + * (Redefine the `src` attribute of all images in a $container, depending on the theme parameters.) + * + * @private + * @param {Object} themeParams + * @param {JQuery} $container + */ + _switchImages: function (themeParams, $container) { + if (!themeParams) { + return; + } + $container.find("img").each(function () { + var $img = $(this); + var src = $img.attr("src"); + + var m = src.match(/^\/web\/image\/\w+\.s_default_image_(?:theme_[a-z]+_)?(.+)$/); + if (!m) { + m = src.match(/^\/\w+\/static\/src\/img\/(?:theme_[a-z]+\/)?s_default_image_(.+)\.[a-z]+$/); + } + if (!m) { + return; + } + + var file = m[1]; + var img_info = themeParams.get_image_info(file); + + if (img_info.format) { + src = "/" + img_info.module + "/static/src/img/theme_" + themeParams.name + "/s_default_image_" + file + "." + img_info.format; + } else { + src = "/web/image/" + img_info.module + ".s_default_image_theme_" + themeParams.name + "_" + file; + } + + $img.attr("src", src); + }); + }, + /** + * Switch themes or import first theme. + * + * @private + * @param {Boolean} firstChoice true if this is the first chosen theme (going from no theme to a theme) + * @param {Object} themeParams + */ + _switchThemes: function (firstChoice, themeParams) { + if (!themeParams || this.switchThemeLast === themeParams) { + return; + } + this.switchThemeLast = themeParams; + + this.$content.closest('body').removeClass(this._allClasses).addClass(themeParams.className); + + var $old_layout = this.$content.find('.o_layout'); + + var $new_wrapper; + var $newWrapperContent; + if (themeParams.nowrap) { + $new_wrapper = $('<div/>', { + class: 'oe_structure' + }); + $newWrapperContent = $new_wrapper; + } else { + // This wrapper structure is the only way to have a responsive + // and centered fixed-width content column on all mail clients + $new_wrapper = $('<table/>', { + class: 'o_mailWrapper' + }); + $newWrapperContent = $('<td/>', { + class: 'o_mail_no_options o_mailWrapper_td oe_structure' + }); + $new_wrapper.append($('<tr/>').append( + $('<td/>', { + class: 'o_mail_no_resize o_not_editable', + contenteditable: 'false' + }), + $newWrapperContent, + $('<td/>', { + class: 'o_mail_no_resize o_not_editable', + contenteditable: 'false' + }) + )); + } + var $newLayout = $('<div/>', { + class: 'o_layout ' + themeParams.className + }).append($new_wrapper); + + var $contents; + if (firstChoice) { + $contents = themeParams.template; + } else if ($old_layout.length) { + $contents = ($old_layout.hasClass('oe_structure') ? $old_layout : $old_layout.find('.oe_structure').first()).contents(); + } else { + $contents = this.$content.find('.note-editable').contents(); + } + + $newWrapperContent.append($contents); + this._switchImages(themeParams, $newWrapperContent); + this.$content.find('.note-editable').empty().append($newLayout); + $old_layout.remove(); + + if (firstChoice) { + $newWrapperContent.find('*').addBack() + .contents() + .filter(function () { + return this.nodeType === 3 && this.textContent.match(/\S/); + }).parent().addClass('o_default_snippet_text'); + + if (themeParams.name == 'basic') { + this.$content.focusIn(); + } + } + this.wysiwyg.snippets.trigger('reload_snippet_dropzones'); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onLoadWysiwyg: function () { + if (this._isFromInline) { + this._fromInline(); + } + this._super(); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onSnippetsLoaded: function (ev) { + var self = this; + var $snippetsSideBar = ev.data; + var $themes = $snippetsSideBar.find("#email_designer_themes").children(); + var $snippets = $snippetsSideBar.find(".oe_snippet"); + var $snippets_menu = $snippetsSideBar.find("#snippets_menu"); + + if (config.device.isMobile) { + $snippetsSideBar.hide(); + console.log(this.$content[0]); + this.$content.attr('style', 'padding-left: 0px !important'); + } + + if ($themes.length === 0) { + return; + } + + /** + * Initialize theme parameters. + */ + this._allClasses = ""; + var themesParams = _.map($themes, function (theme) { + var $theme = $(theme); + var name = $theme.data("name"); + var classname = "o_" + name + "_theme"; + self._allClasses += " " + classname; + var imagesInfo = _.defaults($theme.data("imagesInfo") || {}, { + all: {} + }); + _.each(imagesInfo, function (info) { + info = _.defaults(info, imagesInfo.all, { + module: "mass_mailing", + format: "jpg" + }); + }); + return { + name: name, + className: classname || "", + img: $theme.data("img") || "", + template: $theme.html().trim(), + nowrap: !!$theme.data('nowrap'), + get_image_info: function (filename) { + if (imagesInfo[filename]) { + return imagesInfo[filename]; + } + return imagesInfo.all; + } + }; + }); + $themes.parent().remove(); + + /** + * Create theme selection screen and check if it must be forced opened. + * Reforce it opened if the last snippet is removed. + */ + var $dropdown = $(core.qweb.render("mass_mailing.theme_selector", { + themes: themesParams + })).dropdown(); + + var firstChoice = this._checkIfMustForceThemeChoice(); + + /** + * Add proposition to install enterprise themes if not installed. + */ + var $mail_themes_upgrade = $dropdown.find(".o_mass_mailing_themes_upgrade"); + $mail_themes_upgrade.on("click", function (e) { + e.stopImmediatePropagation(); + e.preventDefault(); + self.do_action("mass_mailing.action_mass_mailing_configuration"); + }); + + /** + * Switch theme when a theme button is hovered. Confirm change if the theme button + * is pressed. + */ + var selectedTheme = false; + $dropdown.on("mouseenter", ".dropdown-item", function (e) { + if (firstChoice) { + return; + } + e.preventDefault(); + var themeParams = themesParams[$(e.currentTarget).index()]; + self._switchThemes(firstChoice, themeParams); + }); + $dropdown.on("mouseleave", ".dropdown-item", function (e) { + self._switchThemes(false, selectedTheme); + }); + $dropdown.on("click", '[data-toggle="dropdown"]', function (e) { + var $menu = $dropdown.find('.dropdown-menu'); + var isVisible = $menu.hasClass('show'); + if (isVisible) { + e.preventDefault(); + e.stopImmediatePropagation(); + $menu.removeClass('show'); + } + }); + + $dropdown.on("click", ".dropdown-item", function (e) { + e.preventDefault(); + e.stopImmediatePropagation(); + var themeParams = themesParams[$(e.currentTarget).index()]; + if (firstChoice) { + self._switchThemes(firstChoice, themeParams); + self.$content.closest('body').removeClass("o_force_mail_theme_choice"); + firstChoice = false; + + if ($mail_themes_upgrade.length) { + $dropdown.remove(); + $snippets_menu.empty(); + } + } + + self._switchImages(themeParams, $snippets); + + selectedTheme = themeParams; + + // Notify form view + self.wysiwyg._onChange(); + $dropdown.find('.dropdown-menu').removeClass('show'); + $dropdown.find('.dropdown-item.selected').removeClass('selected'); + $dropdown.find('.dropdown-item:eq(' + themesParams.indexOf(selectedTheme) + ')').addClass('selected'); + }); + + /** + * If the user opens the theme selection screen, indicates which one is active and + * saves the information... + * ... then when the user closes check if the user confirmed its choice and restore + * previous state if this is not the case. + */ + $dropdown.on("shown.bs.dropdown", function () { + selectedTheme = self._getSelectedTheme(themesParams); + $dropdown.find(".dropdown-item").removeClass("selected").filter(function () { + return ($(this).has(".o_thumb[style=\"" + "background-image: url(" + (selectedTheme && selectedTheme.img) + "_small.png)" + "\"]").length > 0); + }).addClass("selected"); + }); + $dropdown.on("hidden.bs.dropdown", function () { + self._switchThemes(firstChoice, selectedTheme); + }); + + /** + * On page load, check the selected theme and force switching to it (body needs the + * theme style for its edition toolbar). + */ + selectedTheme = this._getSelectedTheme(themesParams); + if (selectedTheme) { + this.$content.closest('body').addClass(selectedTheme.className); + $dropdown.find('.dropdown-item:eq(' + themesParams.indexOf(selectedTheme) + ')').addClass('selected'); + this._switchImages(selectedTheme, $snippets); + } else if (this.$content.find('.o_layout').length) { + themesParams.push({ + name: 'o_mass_mailing_no_theme', + className: 'o_mass_mailing_no_theme', + img: "", + template: this.$content.find('.o_layout').addClass('o_mass_mailing_no_theme').clone().find('oe_structure').empty().end().html().trim(), + nowrap: true, + get_image_info: function () {} + }); + selectedTheme = this._getSelectedTheme(themesParams); + } + + $dropdown.insertAfter($snippets_menu); + }, + /** + * @override + */ + _onTranslate: function () { + this.trigger_up('translate', { + fieldName: this.nodeOptions['inline-field'], + id: this.dataPointID + }); + }, +}); + +fieldRegistry.add('mass_mailing_html', MassMailingFieldHtml); + +return MassMailingFieldHtml; + +}); diff --git a/addons/mass_mailing/static/src/scss/mass_mailing.ui.scss b/addons/mass_mailing/static/src/scss/mass_mailing.ui.scss index d41c5dc15a3..297b241070c 100644 --- a/addons/mass_mailing/static/src/scss/mass_mailing.ui.scss +++ b/addons/mass_mailing/static/src/scss/mass_mailing.ui.scss @@ -9,17 +9,18 @@ .o_mail_theme_selector { > a { @include o-position-absolute(0, 0, auto, 0); - height: $o-navbar-height; - line-height: 31px; + height: $o-wysiwyg-toolbar-height; + line-height: $o-wysiwyg-toolbar-height; border-radius: 0; - background-color: $o-we-color-darker; - color: $o-we-color-text-normal; + background-color: $o-wysiwyg-bg-color; + color: $o-wysiwyg-color; display: flex; justify-content: center; align-items: center; + box-shadow: none !important; &:hover, &:focus, &:active { - color: $o-we-color-text-light; + color: $o-wysiwyg-color-light; } i { @@ -29,15 +30,20 @@ &.show > a { color: white; - background-color: $o-we-color-dark; + background-color: $o-wysiwyg-color; } .dropdown-menu { - @include o-position-absolute($o-navbar-height - 1, -1px, -1px, -1px); + position: absolute; + bottom: -1px; + top: -3px !important; + left: -5px !important; + right: 3px !important; margin: 0; border-radius: 0; overflow: auto; - background-color: $o-we-color-dark; + background-color: $o-wysiwyg-bg-color; + z-index: 1050; .dropdown-item { padding: 10px 10px; @@ -49,8 +55,8 @@ display: none; background-size: cover; padding-top: 50%; - border: 1px solid $o-we-color-light; - border-top: 1px solid $o-we-color-text-normal; + border: 1px solid $o-wysiwyg-border-color; + &.logo { display: block; @@ -59,16 +65,16 @@ &:hover { - background-color: $o-we-color-darker; + background-color: $o-wysiwyg-bg-color; .o_thumb { - border: 1px solid white; + border: 1px solid black; } } &.selected .o_thumb { border: 2px solid $o-brand-odoo; - background-color: $o-we-color-dark; + background-color: $o-wysiwyg-bg-color; } } } @@ -101,8 +107,8 @@ body.o_force_mail_theme_choice { .o_thumb { display: none; padding-top: 107%; - border: 1px solid $o-we-color-light; - border-top: 1px solid $o-we-color-text-normal; + border: 1px solid $o-wysiwyg-color-light; + border-top: 1px solid $o-wysiwyg-border-color; box-shadow: 0 5px 10px rgba(black, 0.8); will-change: transform; backface-visibility: hidden; @@ -123,7 +129,7 @@ body.o_force_mail_theme_choice { } &:hover { - background-color: $o-we-color-dark; + background-color: $o-wysiwyg-color; .o_thumb { box-shadow: 0 5px 30px 1px rgba(black, 0.6); @@ -157,16 +163,29 @@ body.o_force_mail_theme_choice { } } } + .note-editor { + display: none; + } } -#web_editor_inside_iframe { - &.o_basic_theme { - padding-left: 0px !important; - #oe_snippets { - display: none; - } +.o_basic_theme.o_in_iframe { + padding-left: 0px !important; + #oe_snippets { + display: none; } - #wrapwrap > main #editable_area { - padding: 24px; + .note-editor.o_snippets_loaded { + padding-left: 0px!important; } } + +.note-editable .o_layout { + overflow: initial; +} + +.oe_structure { + width: 100%; +} + +:root { + font-size: 14px; +} diff --git a/addons/mass_mailing/static/src/scss/themes/theme_default.scss b/addons/mass_mailing/static/src/scss/themes/theme_default.scss index 2fcc5bf2915..afd4057cfc9 100644 --- a/addons/mass_mailing/static/src/scss/themes/theme_default.scss +++ b/addons/mass_mailing/static/src/scss/themes/theme_default.scss @@ -15,6 +15,7 @@ $o-mm-def-color-eta: #87a6b5; $o-mm-def-body-width : 600px; $o-mm-def-body-bobile : 480px; $o-mm-def-b-radius : 2px; +$o-mm-def-body-bg : $o-mm-def-color-beta; $o-mm-def-font : -apple-system, "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; $o-mm-def-text-color : $o-mm-def-color-delta; @@ -37,9 +38,9 @@ $o-mm-def-btn-text : $o-mm-def-color-beta; background-color: $o-mm-def-color-alpha; } -td { +td:not([align]) { // Default browser style but needed so that alignment works on some mail - // clients (see transcoder) + // clients (see convert_inline) text-align: inherit; } @@ -54,10 +55,10 @@ td { // Forces on <p/> and <hx/> elements as several mail clients does not // correctly inherit font properties - &, p { + &, p, ol { font-size: 14px; // force bootstrap default } - &, p, h1, h2, h3, h4, h5, h6 { + &, p, h1, h2, h3, h4, h5, h6, ol { font-family: $o-mm-def-font; color: lighten($o-mm-def-text-color, 20%); } @@ -81,6 +82,7 @@ td { width: 100%; max-width: $o-mm-def-body-width; // should not be necessary thanks to mail wrapper margin: 0 auto; + background-color: $o-mm-def-body-bg; h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { color: $o-mm-def-text-color; diff --git a/addons/mass_mailing/static/tests/mass_mailing_html_tests.js b/addons/mass_mailing/static/tests/mass_mailing_html_tests.js new file mode 100644 index 00000000000..36bea2db624 --- /dev/null +++ b/addons/mass_mailing/static/tests/mass_mailing_html_tests.js @@ -0,0 +1,138 @@ +odoo.define('mass_mailing.field_html_tests', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var FormView = require('web.FormView'); +var testUtils = require('web.test_utils'); +var weTestUtils = require('web_editor.test_utils'); +var Wysiwyg = require('web_editor.wysiwyg'); + + +QUnit.module('mass_mailing', {}, function () { +QUnit.module('field html', { + beforeEach: function () { + this.data = weTestUtils.wysiwygData({ + 'mail.mass_mailing': { + fields: { + display_name: { + string: "Displayed name", + type: "char" + }, + body_html: { + string: "Message Body inline (to send)", + type: "html" + }, + body_arch: { + string: "Message Body for edition", + type: "html" + }, + }, + records: [{ + id: 1, + display_name: "first record", + body_html: "<div class='field_body' style='background-color: red;'><p>code to edit</p></div>", + body_arch: "<div class='field_body'><p>code to edit</p></div>", + }], + }, + }); + + testUtils.mock.patch(ajax, { + loadAsset: function (xmlId) { + if (xmlId === 'template.assets') { + return $.when({ + cssLibs: [], + cssContents: ['.field_body {background-color: red;}'] + }); + } + if (xmlId === 'template.assets_all_style') { + return $.when({ + cssLibs: $('head link[href]:not([type="image/x-icon"])').map(function () { + return $(this).attr('href'); + }).get(), + cssContents: ['.field_body {background-color: red;}'] + }); + } + throw 'Wrong template'; + }, + }); + }, + afterEach: function () { + testUtils.mock.unpatch(ajax); + }, +}, function () { + +QUnit.test('save arch and html', function (assert) { + var done = assert.async(); + assert.expect(6); + + testUtils.createAsyncView({ + View: FormView, + model: 'mail.mass_mailing', + data: this.data, + arch: '<form>' + + ' <field name="body_html" class="oe_read_only" widget="html"'+ + ' options="{'+ + ' \'cssReadonly\': \'template.assets\','+ + ' }"'+ + ' />'+ + ' <field name="body_arch" class="oe_edit_only" widget="mass_mailing_html"'+ + ' options="{'+ + ' \'snippets\': \'template.assets\','+ + ' \'cssEdit\': \'template.assets\','+ + ' \'inline-field\': \'body_html\''+ + ' }"'+ + ' />'+ + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === "write") { + var values = args.args[1]; + assert.strictEqual(values.body_html, + '<div class="field_body" style="background-color:red;"><p>c<b>od</b>e to edit</p></div>', + "should save the content"); + assert.strictEqual(values.body_arch, + '<div class="field_body"><p>c<b>od</b>e to edit</p></div>', + "should save the content"); + + } + return this._super.apply(this, arguments); + }, + }).then(function (form) { + var $fieldReadonly = form.$('.oe_form_field[name="body_html"]'); + var $fieldEdit = form.$('.oe_form_field[name="body_arch"]'); + + assert.strictEqual($fieldReadonly.css('display'), 'block', "should display the readonly mode"); + assert.strictEqual($fieldEdit.css('display'), 'none', "should hide the edit mode"); + + form.$buttons.find('.o_form_button_edit').click(); + + $fieldReadonly = form.$('.oe_form_field[name="body_html"]'); + $fieldEdit = form.$('.oe_form_field[name="body_arch"]'); + + assert.strictEqual($fieldReadonly.css('display'), 'none', "should hide the readonly mode"); + assert.strictEqual($fieldEdit.css('display'), 'block', "should display the edit mode"); + + var $iframe = $fieldEdit.find('iframe'); + + $iframe.data('load-def').then(function () { + var doc = $iframe.contents()[0]; + var $content = $('#iframe_target', doc); + + // select the text + var pText = $content.find('.note-editable p').first().contents()[0]; + Wysiwyg.setRange(pText, 1, pText, 3); + // text is selected + + $content.find('.note-toolbar .note-font .note-btn-bold').mousedown().click(); + + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + done(); + }); + }); +}); + +}); +}); +}); diff --git a/addons/mass_mailing/views/editor_field_html.xml b/addons/mass_mailing/views/editor_field_html.xml index 513be80933a..25f2be6fe23 100644 --- a/addons/mass_mailing/views/editor_field_html.xml +++ b/addons/mass_mailing/views/editor_field_html.xml @@ -6,42 +6,27 @@ <t t-call="mass_mailing.mass_mailing_mail_style"/> </template> -<template id="FieldTextHtmlInline" name="Editor layout"> - <t t-call="web_editor.layout"> - <t t-set="head_mm_field_text_html_inline"> - <t t-if="enable_editor"> - <t t-call-assets="mass_mailing.assets_mail_themes"/> - <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_editor.js"></script> - </t> - <t t-else=""> - <link rel="stylesheet" href="/mass_mailing/static/src/css/basic_theme_readonly.css" type="text/css"/> - </t> - <base target="_parent"/> - </t> - <t t-set="head" t-value="head_mm_field_text_html_inline + (head or '')"/> +<template id="assets_mail_themes_edition"> <!-- maybe to remove and convert into a field dumy with attr invisible if the template is not selected --> + <t t-call="web._assets_helpers"/> + <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/webclient.scss"/> + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/wysiwyg_variables.scss"/> + <link rel="stylesheet" type="text/scss" href="/mass_mailing/static/src/scss/mass_mailing.ui.scss"/> +</template> + +<template id="iframe_css_assets_edit"> + <t t-call-assets="web.assets_common" t-js="false"/> + <t t-call-assets="web.assets_frontend" t-js="false"/> + <t t-call-assets="mass_mailing.assets_mail_themes" t-js="false"/> + <t t-call-assets="mass_mailing.assets_mail_themes_edition" t-js="false"/> +</template> - <div t-if="not edit_translations" id="editable_area" class="o_editable o_mail_area" t-att-data-oe-model="model" t-att-data-oe-field="field" t-att-data-oe-id="res_id" data-oe-type="html"><t t-raw="content"/></div> - <div t-if="edit_translations" id="editable_area" class="o_mail_area"><t t-raw="content"/></div> - </t> +<template id="iframe_css_assets_readonly"> + <link rel="stylesheet" type="text/scss" href="/mass_mailing/static/src/css/basic_theme_readonly.css"/> </template> -<template id="FieldTextHtmlPopupContent" name="Editor layout"> - <t t-call="web_editor.layout"> - <t t-set="head"> - <link rel="stylesheet" href="/mass_mailing/static/src/css/mass_mailing_popup.css" type="text/css"/> - </t> - <div role="dialog" class="modal-dialog modal-md d-block" id="o_newsletter_popup"> - <div class="modal-content o_popup_modal_content"> - <div class="o_popup_modal_body text-center"> - <div class="o_popup_content_dev"> - <div id="editable_area" class="o_editable o_mail_area" t-att-data-oe-model="model" t-att-data-oe-field="field" t-att-data-oe-id="res_id" data-oe-type="html"> - <!-- Can be removed once default values are ok for the editor --> - <t t-raw="content"/> - </div> - </div> - </div> - </div> - </div> - </t> - </template> +<template id="qunit_suite" inherit_id="web.qunit_suite"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/mass_mailing/static/tests/mass_mailing_html_tests.js"/> + </xpath> +</template> </odoo> diff --git a/addons/mass_mailing/views/mass_mailing_template.xml b/addons/mass_mailing/views/mass_mailing_template.xml index 7fc15365001..359391e95c5 100644 --- a/addons/mass_mailing/views/mass_mailing_template.xml +++ b/addons/mass_mailing/views/mass_mailing_template.xml @@ -4,6 +4,10 @@ <xpath expr="." position="inside"> <link rel="stylesheet" type="text/scss" href="/mass_mailing/static/src/scss/mass_mailing.scss"/> <link rel="stylesheet" href="/mass_mailing/static/src/css/email_template.css"/> + + <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing.js"></script> + <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_widget.js"></script> + <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_snippets.js"></script> </xpath> <xpath expr="//script[last()]" position="after"> <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_campaign_kanban_record.js"></script> @@ -12,14 +16,7 @@ <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_list_kanban_record.js"></script> <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_list_kanban_renderer.js"></script> <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_list_kanban_view.js"></script> - <script type="text/javascript" src="/mass_mailing/static/src/js/mass_mailing_field_text_html.js"></script> <script type="text/javascript" src="/mass_mailing/static/src/js/unsubscribe.js"></script> </xpath> </template> - - <template id="assets_editor" inherit_id="web_editor.assets_editor"> - <xpath expr="//link[last()]" position="after"> - <link rel="stylesheet" type="text/scss" href="/mass_mailing/static/src/scss/mass_mailing.ui.scss"/> - </xpath> - </template> </odoo> diff --git a/addons/mass_mailing/views/mass_mailing_views.xml b/addons/mass_mailing/views/mass_mailing_views.xml index 56f8326cbec..9a8d203b67a 100644 --- a/addons/mass_mailing/views/mass_mailing_views.xml +++ b/addons/mass_mailing/views/mass_mailing_views.xml @@ -611,7 +611,14 @@ </group> <notebook> <page string="Mail Body"> - <field name="body_html" class="o_mail_body" widget="mass_mailing_html_frame" options="{'editor_url': '/mass_mailing/field/email_template'}"/> + <field name="body_html" class="oe_read_only" widget="html" options="{ + 'cssReadonly': 'mass_mailing.iframe_css_assets_readonly', + }"/> + <field name="body_arch" class="o_mail_body oe_edit_only" widget="mass_mailing_html" options="{ + 'snippets': 'mass_mailing.email_designer_snippets', + 'cssEdit': 'mass_mailing.iframe_css_assets_edit', + 'inline-field': 'body_html' + }"/> </page> <page string="Options"> <group> diff --git a/addons/mass_mailing/views/snippets_themes.xml b/addons/mass_mailing/views/snippets_themes.xml index b0b1cf6398e..3e3ccc1889e 100644 --- a/addons/mass_mailing/views/snippets_themes.xml +++ b/addons/mass_mailing/views/snippets_themes.xml @@ -3,6 +3,7 @@ <!-- Snippets & Themes Menu --> <template id="email_designer_snippets" inherit_id="web_editor.snippets" primary="True"> <xpath expr="//h2[@id='snippets_menu']" position="replace"> + <t t-set="company_id" t-value="res_company"/> <h2 id="snippets_menu">Select a template</h2> </xpath> <xpath expr="//div[@id='o_scroll']" position="replace"> diff --git a/addons/mass_mailing/views/snippets_themes_options.xml b/addons/mass_mailing/views/snippets_themes_options.xml index 61a165d783f..7700dd00d14 100644 --- a/addons/mass_mailing/views/snippets_themes_options.xml +++ b/addons/mass_mailing/views/snippets_themes_options.xml @@ -5,11 +5,11 @@ <t t-call="web_editor.snippet_options"/> <t t-raw="0"/> - <div data-js="sizing_x" + <div data-js="mass_mailing_sizing_x" data-selector="img, .mv, .col_mv, td, th" data-exclude=".o_mail_no_resize, .o_mail_no_options"/> - <div data-js="table_item" + <div data-js="mass_mailing_table_item" data-selector="td, th" data-exclude=".o_mail_no_options"/> @@ -29,17 +29,17 @@ data-drop-near=".col_mv, td, th"/> <div data-js="content" - data-selector="[data-oe-field='body_html'] > div:not(.o_layout), [data-oe-field='body_html'] .oe_structure > div, .oe_snippet_body" + data-selector=".note-editable > div:not(.o_layout), .note-editable .oe_structure > div, .oe_snippet_body" data-exclude=".o_mail_no_options" data-drop-near="[data-oe-field='body_html']:not(:has(.o_layout)) > *, .oe_structure > *" data-drop-in="[data-oe-field='body_html']:not(:has(.o_layout)), .oe_structure"/> <div data-js="sizing_y" - data-selector="[data-oe-field='body_html'] > div:not(.o_layout), [data-oe-field='body_html'] .oe_structure > div, td, th" + data-selector=".note-editable > div:not(.o_layout), .note-editable .oe_structure > div, td, th" data-exclude=".o_mail_no_resize, .o_mail_no_options"/> <div data-js="colorpicker" - data-selector="[data-oe-field='body_html'] > div:not(.o_layout), [data-oe-field='body_html'] .oe_structure > div, td, th" + data-selector=".note-editable > div:not(.o_layout), .note-editable .oe_structure > div, td, th" data-exclude=".o_mail_no_colorpicker, .o_mail_no_options"> <div class="dropdown-submenu"> <a tabindex="-1" href="#" class="dropdown-item"><i class="fa fa-eyedropper"/>Background Color</a> diff --git a/addons/mass_mailing/views/unsubscribe_templates.xml b/addons/mass_mailing/views/unsubscribe_templates.xml index 621119c35b6..678cf7f252a 100644 --- a/addons/mass_mailing/views/unsubscribe_templates.xml +++ b/addons/mass_mailing/views/unsubscribe_templates.xml @@ -111,6 +111,7 @@ <t t-set="head"> <t t-call-assets="web.assets_common"/> <t t-call-assets="mass_mailing.assets_backend"/> + <script type="text/javascript" src="/mass_mailing/static/src/js/unsubscribe.js"></script> </t> <body class="o_white_body"> <header> diff --git a/addons/note/data/note_demo.xml b/addons/note/data/note_demo.xml index c02acf96ef2..6baf90f6f33 100644 --- a/addons/note/data/note_demo.xml +++ b/addons/note/data/note_demo.xml @@ -3,69 +3,67 @@ <data noupdate="1"> <record id="note_1" model="note.note"> <field name="name">Customer report #349872</field> - <field name="memo"><![CDATA[<b>Customer report #349872</b> - <br/><br/>* Calendar app in Home - <br/>* The calendar module should create a menu in Home, like described above. - <br/>* This module should become a main application (in the first screen at installation) - <br/>* We should use the term Calendar, not Meeting. - ]]> + <field name="memo"><![CDATA[<p><b>Customer report #349872</b></p> + <p><br/></p> + <p>* Calendar app in Home</p> + <p>* The calendar module should create a menu in Home, like described above.</p> + <p>* This module should become a main application (in the first screen at installation)</p> + <p>* We should use the term Calendar, not Meeting.</p>]]> </field> <field name="user_id" ref="base.user_demo"/> <field name="color">2</field> </record> <record id="note_2" model="note.note"> - <field name="memo"><![CDATA[<b>Call Raoulette</b> - <br/><br/>* Followed by the telephone conversation and mail about D.544.3 - ]]> + <field name="memo"><![CDATA[<p><b>Call Raoulette</b></p> + <p><br/></p> + <p>* Followed by the telephone conversation and mail about D.544.3</p>]]> </field> <field name="user_id" ref="base.user_demo"/> </record> <record id="note_4" model="note.note"> - <field name="memo"><![CDATA[<b>Project N.947.5</b> - <br/><br/>]]> + <field name="memo"><![CDATA[<p><b>Project N.947.5</b></p>]]> </field> <field name="stage_id" ref="note_stage_02"/> <field name="user_id" ref="base.user_admin"/> </record> <record id="note_5" model="note.note"> - <field name="memo"><![CDATA[<b>Shop for family dinner</b> - <br/>* stuffed turkey - <br/>* wine - ]]> + <field name="memo"><![CDATA[<p><b>Shop for family dinner</b></p> + <p>* stuffed turkey</p> + <p>* wine</p>]]> </field> <field name="user_id" ref="base.user_demo"/> </record> <record id="note_6" model="note.note"> - <field name="memo"><![CDATA[<b>Idea to develop</b> - <br/><br/>* Create a module note_pad - it transforms the html editable memo text field into widget='pad', similar to project_pad depends on 'memo' and 'pad' modules - ]]> + <field name="memo"><![CDATA[<p><b>Idea to develop</b></p> + <p><br/></p> + <p>* Create a module note_pad + it transforms the html editable memo text field into widget='pad', similar to project_pad depends on 'memo' and 'pad' modules</p>]]> </field> <field name="stage_id" ref="note_stage_02"/> <field name="user_id" ref="base.user_admin"/> </record> <record id="note_8" model="note.note"> - <field name="memo"><![CDATA[<b>New computer specs</b> - <br/><br/>* Motherboard + <field name="memo"><![CDATA[<p><b>New computer specs</b></p> + <p><br/></p> + <p>* Motherboard according to processor - <br/>* Processor + </p><p>* Processor need to decide - <br/>* Graphic card + </p><p>* Graphic card with great performance for games ! - <br/>* Hard drive + </p><p>* Hard drive big, for lot of internet backups - <br/>* Tower + </p><p>* Tower silent, better when watching films - <br/>* Blueray drive ? + </p><p>* Blueray drive ? is it interesting yet ? - <br/>* Screen - a big one, full of pixels, of course ! - ]]> + </p><p>* Screen + a big one, full of pixels, of course !</p>]]> </field> <field name="stage_id" ref="note_stage_03"/> <field name="color">3</field> @@ -73,19 +71,19 @@ </record> <record id="note_9" model="note.note"> - <field name="memo"><![CDATA[<b>Read those books</b> - <br/><br/>* Odoo: a modern approach to integrated business management - <br/>* Odoo for Retail and Industrial Management - ]]> + <field name="memo"><![CDATA[<p><b>Read those books</b></p> + <p><br/></p> + <p>* Odoo: a modern approach to integrated business management</p> + <p>* Odoo for Retail and Industrial Management</p>]]> </field> <field name="user_id" ref="base.user_demo"/> </record> <record id="note_12" model="note.note"> - <field name="memo"><![CDATA[<b>Read some documentation about Odoo before diving into the code</b> - <br/><br/>* Odoo: a modern approach to integrated business management - <br/>* Odoo for Retail and Industrial Management - ]]> + <field name="memo"><![CDATA[<p><b>Read some documentation about Odoo before diving into the code</b></p> + <p><br/></p> + <p>* Odoo: a modern approach to integrated business management</p> + <p>* Odoo for Retail and Industrial Management</p>]]> </field> <field name="color">7</field> <field name="stage_ids" eval="['note_stage_03']"/> diff --git a/addons/point_of_sale/controllers/__init__.py b/addons/point_of_sale/controllers/__init__.py index 8fa0f379742..12a7e529b67 100644 --- a/addons/point_of_sale/controllers/__init__.py +++ b/addons/point_of_sale/controllers/__init__.py @@ -1,2 +1 @@ from . import main -from . import web_editor diff --git a/addons/point_of_sale/controllers/web_editor.py b/addons/point_of_sale/controllers/web_editor.py deleted file mode 100644 index bb423f28063..00000000000 --- a/addons/point_of_sale/controllers/web_editor.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from odoo import http -from odoo.http import request -from odoo.addons.web_editor.controllers.main import Web_Editor - - -class WebEditorPointOfSale(Web_Editor): - @http.route('/point_of_sale/field/customer_facing_display_template', type='http', auth="user") - def get_field_text_html(self, model=None, res_id=None, field=None, callback=None, **kwargs): - kwargs['snippets'] = '/point_of_sale/snippets' - kwargs['template'] = 'point_of_sale.FieldTextHtml' - extra_head = request.env.ref('point_of_sale.extra_head').render(None) - - return self.FieldTextHtml(model=model, res_id=res_id, field=field, callback=callback, head=extra_head, **kwargs) - - @http.route(['/point_of_sale/snippets'], type='json', auth="user", website=True) - def get_snippets(self): - return request.env.ref('point_of_sale.customer_facing_display_snippets').render(None) diff --git a/addons/point_of_sale/static/src/css/customer_facing_display.css b/addons/point_of_sale/static/src/css/customer_facing_display.css index 60250405062..e8e97cf4c7e 100644 --- a/addons/point_of_sale/static/src/css/customer_facing_display.css +++ b/addons/point_of_sale/static/src/css/customer_facing_display.css @@ -450,9 +450,6 @@ body.editor_enable .pos-hidden { top: 35px; } } -body.editor_enable #web_editor-toolbars .btn-group:not(.note-history) { - display: none; -} body.editor_enable #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group > .dropdown.oe_options.btn-group.snippet-option-pos_palette > ul { min-width: 100px; max-width: 100px; diff --git a/addons/point_of_sale/static/src/js/field_text_html.js b/addons/point_of_sale/static/src/js/field_text_html.js deleted file mode 100644 index 27b378801c9..00000000000 --- a/addons/point_of_sale/static/src/js/field_text_html.js +++ /dev/null @@ -1,20 +0,0 @@ -odoo.define('point_of_sale.fieldtexthtml', function (require) { - 'use strict'; - - var FieldTextHtml = require('web_editor.backend').FieldTextHtml; - - FieldTextHtml.include({ - // avoid '414 Request-URI Too Large' errors to - // /point_of_sale/field/customer_facing_display_template by - // filtering out biggest fields - getDatarecord: function () { - var datarecord = this._super(); - if (this.model === 'pos.config') { - datarecord = _.omit(datarecord, function (val, key) { - return _.isObject(val) || key === 'customer_facing_display_html'; - }); - } - return datarecord; - }, - }); -}); diff --git a/addons/point_of_sale/static/src/scss/customer_facing_display.scss b/addons/point_of_sale/static/src/scss/customer_facing_display.scss index 7872220bc23..766ac34cb1e 100644 --- a/addons/point_of_sale/static/src/scss/customer_facing_display.scss +++ b/addons/point_of_sale/static/src/scss/customer_facing_display.scss @@ -432,13 +432,6 @@ body { } } - // =========== EDITOR CUSTOMIZATIONS =========== - #web_editor-toolbars { - .btn-group:not(.note-history) { - display: none; - } - } - #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group> .dropdown.oe_options.btn-group.snippet-option-pos_palette > ul { min-width: 100px; max-width: 100px; diff --git a/addons/point_of_sale/views/point_of_sale.xml b/addons/point_of_sale/views/point_of_sale.xml index b1bc1d540b6..9e07dff51bc 100644 --- a/addons/point_of_sale/views/point_of_sale.xml +++ b/addons/point_of_sale/views/point_of_sale.xml @@ -25,23 +25,10 @@ <script type="text/javascript" src="/point_of_sale/static/src/js/tests.js"></script> </template> - <template id="FieldTextHtml"> - <t t-set="head"> - <t t-raw="head"/> - <t t-if="enable_editor"> - <link rel="stylesheet" href="/point_of_sale/static/src/css/customer_facing_display_editor_thumb.css" type="text/css"/> - </t> - </t> - <t t-call="web_editor.layout"> - <div id="editable_area" - class="o_editable" - t-att-data-oe-model="model" - t-att-data-oe-field="field" - t-att-data-oe-id="res_id" - data-oe-type="html"> - <t t-raw="content" /> - </div> - </t> + <template id="wysiwyg_iframe_css_assets" name="CSS assets for wysiwyg iframe content"> + <t t-call-assets="web.assets_common" t-js="false"/> + <t t-call-assets="web.assets_backend" t-js="false"/> + <link rel="stylesheet" href="/point_of_sale/static/src/css/customer_facing_display_editor_thumb.css" type="text/css"/> </template> <template id="customer_facing_display_html" > diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index 8dd29cb7bfe..2994faa3078 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -181,7 +181,10 @@ <div class="text-muted" attrs="{'invisible' : ['|', ('iface_customer_facing_display', '=', False), '&', ('id', '!=', False), ('customer_facing_display_html', '!=', '')]}">Save this configuration to see and edit the customer display</div> <div class="row mt16 o_settings_container" attrs="{'invisible' : ['|', '|',('iface_customer_facing_display', '=', False), ('id', '=', False), ('customer_facing_display_html', '=', '')]}"> <div class="col-12 col-lg-6 o_setting_box"> - <field name="customer_facing_display_html" widget="html_frame" nolabel="1" options="{'editor_url': '/point_of_sale/field/customer_facing_display_template'}"/> + <field name="customer_facing_display_html" widget="html" nolabel="1" options="{ + 'snippets': 'point_of_sale.customer_facing_display_snippets', + 'cssEdit': 'point_of_sale.wysiwyg_iframe_css_assets' + }"/> </div> </div> </div> diff --git a/addons/point_of_sale/views/pos_templates.xml b/addons/point_of_sale/views/pos_templates.xml index 88a108bc12c..c2721c17e55 100644 --- a/addons/point_of_sale/views/pos_templates.xml +++ b/addons/point_of_sale/views/pos_templates.xml @@ -32,8 +32,6 @@ <t t-call-assets="web.assets_common" t-css="false"/> <t t-call-assets="web.assets_backend" t-css="false"/> - <t t-call-assets="web_editor.summernote" t-css="false"/> - <t t-call-assets="web_editor.assets_editor" t-css="false"/> <t t-call-assets="point_of_sale.assets"/> <script type="text/javascript" id="loading-script" t-raw="init"> @@ -71,13 +69,7 @@ </script> </template> -<template id="pos_editor_fieldtexthtml_assets" inherit_id="web.assets_backend"> - <xpath expr="." position="inside"> - <script type="text/javascript" src="/point_of_sale/static/src/js/field_text_html.js"/> - </xpath> -</template> - -<template id="pos_editor_assets" inherit_id="web_editor.assets_editor"> +<template id="pos_editor_assets" inherit_id="web_editor.wysiwyg_snippets"> <xpath expr="//script[last()]" position="after"> <script type="text/javascript" src="/point_of_sale/static/src/js/pos.web_editor.js"/> </xpath> diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss index 26beabb5865..e3c11d8f26d 100644 --- a/addons/portal/static/src/scss/portal.scss +++ b/addons/portal/static/src/scss/portal.scss @@ -103,43 +103,43 @@ a, .btn-link { ul { list-style-type: disc; } -li > ul { +ul ul { list-style-type: circle; } -li > * > li > ul { +ul ul ul { list-style-type: square; } -li > * > li > * > li > ul { +ul ul ul ul { list-style-type: disc; } -li > * > li > * > li > * > li > ul { +ul ul ul ul ul { list-style-type: circle; } -li > * > li > * > li > * > li > * > li > ul { +ul ul ul ul ul ul { list-style-type: square; } -li > * > li > * > li > * > li > * > li > * > li > ul { +ul ul ul ul ul ul ul { list-style-type: disc; } ol { list-style-type: decimal; } -li > ol { +ol ol { list-style-type: lower-alpha; } -li > * > li > ol { +ol ol ol { list-style-type: lower-greek; } -li > * > li > * > li > ol { +ol ol ol ol { list-style-type: decimal; } -li > * > li > * > li > * > li > ol { +ol ol ol ol ol { list-style-type: lower-alpha; } -li > * > li > * > li > * > li > * > li > ol { +ol ol ol ol ol ol { list-style-type: lower-greek; } -li > * > li > * > li > * > li > * > li > * > li > ol { +ol ol ol ol ol ol ol { list-style-type: decimal; } li > p { diff --git a/addons/sale/static/src/js/product_configurator_controller.js b/addons/sale/static/src/js/product_configurator_controller.js index a2b59cf835d..bf45779bd31 100644 --- a/addons/sale/static/src/js/product_configurator_controller.js +++ b/addons/sale/static/src/js/product_configurator_controller.js @@ -129,7 +129,8 @@ var ProductConfiguratorFormController = FormController.extend({ pricelistId: self.renderer.pricelistId, okButtonText: _t('Confirm'), cancelButtonText: _t('Back'), - title: _t('Configure') + title: _t('Configure'), + context: self.initialState.context, }).open(); self.optionalProductsModal.on('options_empty', null, diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js index 630774b8da5..c30c10b92b9 100644 --- a/addons/sale/static/src/js/product_configurator_modal.js +++ b/addons/sale/static/src/js/product_configurator_modal.js @@ -5,7 +5,6 @@ var ajax = require('web.ajax'); var Dialog = require('web.Dialog'); var ServicesMixin = require('web.ServicesMixin'); var ProductConfiguratorMixin = require('sale.ProductConfiguratorMixin'); -var weContext = require('web_editor.context'); var productNameMap = {}; var optionalProductsMap = {}; @@ -62,6 +61,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi this._super(parent, options); + this.context = params.context; this.rootProduct = params.rootProduct; this.container = parent; this.pricelistId = params.pricelistId; @@ -88,7 +88,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi kwargs: { context: _.extend({ 'quantity': self.rootProduct.quantity - }, weContext.get()), + }, this.context), } }) .then(function (modalContent) { diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js index 7a6de7a3cfe..954ac594262 100644 --- a/addons/sale_timesheet/static/src/js/timesheet_plan.js +++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js @@ -100,7 +100,7 @@ var ProjectPlan = AbstractAction.extend({ _updateControlPanel: function (buttons) { // set actions button if (this.$buttons) { - this.$buttons.off().destroy(); + this.$buttons.off().remove(); } buttons = buttons || []; this.$buttons = $(QWeb.render("project.plan.ControlButtons", {'buttons': buttons})); diff --git a/addons/survey/static/src/js/survey.js b/addons/survey/static/src/js/survey.js index b49999adc4e..200928f428c 100644 --- a/addons/survey/static/src/js/survey.js +++ b/addons/survey/static/src/js/survey.js @@ -6,7 +6,6 @@ var core = require('web.core'); var time = require('web.time'); var ajax = require('web.ajax'); var base = require('web_editor.base'); -var context = require('web_editor.context'); var field_utils = require('web.field_utils'); var _t = core._t; @@ -166,7 +165,7 @@ if(!the_form.length) { }); function load_locale(){ - var url = "/web/webclient/locale/" + context.get().lang || 'en_US'; + var url = "/web/webclient/locale/" + (document.documentElement.getAttribute('lang') || 'en_US').replace('-', '_'); return ajax.loadJS(url); } diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index f844cbdaed4..1821d5a3be4 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -1,6 +1,7 @@ odoo.define('web.ajax', function (require) { "use strict"; +var config = require('web.config'); var core = require('web.core'); var utils = require('web.utils'); var time = require('web.time'); @@ -503,6 +504,55 @@ var loadXML = (function () { }; })(); +/** + * Loads a template file according to the given xmlId. + * + * @param {string} [xmlId] - the template xmlId + * @returns {Deferred} resolved with an object + * cssLibs: list of css files + * cssContents: list of style tag contents + * jsLibs: list of JS files + * jsContents: list of script tag contents + */ +var loadAsset = (function () { + var cache = {}; + + var load = function loadAsset(xmlId) { + if (cache[xmlId]) { + return $.when(cache[xmlId]); + } + var params = { + args: [xmlId, { + debug: config.debug + }], + kwargs: { + context: odoo.session_info.user_context, + }, + method: 'render_template', + model: 'ir.ui.view', + }; + return rpc('/web/dataset/call_kw/ir.ui.view/render_template', params).then(function (xml) { + var $xml = $(xml); + cache[xmlId] = { + cssLibs: $xml.filter('link[href]:not([type="image/x-icon"])').map(function () { + return $(this).attr('href'); + }).get(), + cssContents: $xml.filter('style').map(function () { + return $(this).html(); + }).get(), + jsLibs: $xml.filter('script[src]').map(function () { + return $(this).attr('src'); + }).get(), + jsContents: $xml.filter('script:not([src])').map(function () { + return $(this).html(); + }).get(), + }; + return cache[xmlId]; + }); + }; + + return load; +})(); /** * Loads the given js and css libraries. Note that the ajax loadJS and loadCSS methods @@ -515,6 +565,8 @@ var loadXML = (function () { * parallel. * @param {Array<string>} [libs.cssLibs=[]] A list of css files, to be loaded in * parallel + * @param {Array<string>} [libs.assetLibs=[]] A list of xmlId. The loaded template + * contains the script and link to be loaded * * @returns {Deferred} */ @@ -534,6 +586,11 @@ function loadLibs (libs) { _.each(libs.cssLibs || [], function (url) { defs.push(ajax.loadCSS(url)); }); + _.each(libs.assetLibs || [], function (xmlId) { + defs.push(loadAsset(xmlId).then(function (asset) { + return loadLibs(asset); + })); + }); return $.when.apply($, defs); } @@ -544,6 +601,7 @@ var ajax = { loadCSS: loadCSS, loadJS: loadJS, loadXML: loadXML, + loadAsset: loadAsset, loadLibs: loadLibs, get_file: get_file, post: post, diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js index c31feb61a8f..97642f8e438 100644 --- a/addons/web/static/src/js/core/domain.js +++ b/addons/web/static/src/js/core/domain.js @@ -104,8 +104,13 @@ var Domain = collections.Tree.extend({ ); case "like": return (fieldValue.toLowerCase().indexOf(this._data[2].toLowerCase()) >= 0); + case "=like": + var regExp = new RegExp(this._data[2].toLowerCase().replace(/([.\[\]\{\}\+\*])/g, '\\\$1').replace(/%/g, '.*')); + return regExp.test(fieldValue.toLowerCase()); case "ilike": return (fieldValue.indexOf(this._data[2]) >= 0); + case "=ilike": + return new RegExp(this._data[2].replace(/%/g, '.*'), 'i').test(fieldValue); default: throw new Error(_.str.sprintf( "Domain %s uses an unsupported operator", diff --git a/addons/web/static/src/js/report/client_action.js b/addons/web/static/src/js/report/client_action.js index 3a520e3d6b5..099ee77a527 100644 --- a/addons/web/static/src/js/report/client_action.js +++ b/addons/web/static/src/js/report/client_action.js @@ -2,7 +2,6 @@ odoo.define('report.client_action', function (require) { 'use strict'; var AbstractAction = require('web.AbstractAction'); -var config = require('web.config'); var core = require('web.core'); var session = require('web.session'); var utils = require('report.utils'); @@ -11,8 +10,6 @@ var QWeb = core.qweb; var AUTHORIZED_MESSAGES = [ - 'report.editor:save_ok', - 'report.editor:discard_ok', 'report:do_action', ]; @@ -28,8 +25,6 @@ var ReportAction = AbstractAction.extend({ this.action_manager = parent; this._title = options.display_name || options.name; - this.edit_mode_available = false; - this.in_edit_mode = false; this.report_url = options.report_url; // Extra info that will be useful to build a qweb-pdf action. @@ -49,20 +44,12 @@ var ReportAction = AbstractAction.extend({ self.trusted_origin = utils.build_origin(trusted_protocol, trusted_host); self.$buttons = $(QWeb.render('report.client_action.ControlButtons', {})); - self.$buttons.on('click', '.o_report_edit', self.on_click_edit); self.$buttons.on('click', '.o_report_print', self.on_click_print); - self.$buttons.on('click', '.o_report_save', self.on_click_save); - self.$buttons.on('click', '.o_report_discard', self.on_click_discard); self._update_control_panel(); // Load the report in the iframe. Note that we use a relative URL. self.iframe.src = self.report_url; - - // Once the iframe is loaded, check if we can edit the report. - self.iframe.onload = function () { - self._on_iframe_loaded(); - }; }); }, @@ -82,31 +69,12 @@ var ReportAction = AbstractAction.extend({ $(window).off('message', this.on_message_received); }, - _on_iframe_loaded: function () { - var editable = $(this.iframe).contents().find('html').data('editable'); - if (editable === 1) { - this.edit_mode_available = true; - this._update_control_panel(); - } - }, - _update_control_panel: function () { this.updateControlPanel({ cp_content: { $buttons: this.$buttons, }, }); - this._update_control_panel_buttons(); - }, - - /** - * Helper allowing to toggle groups of buttons in the control panel - * according to the `this.in_edit_mode` flag. - */ - _update_control_panel_buttons: function () { - this.$buttons.filter('div.o_report_edit_mode').toggle(this.in_edit_mode); - this.$buttons.filter('div.o_report_no_edit_mode').toggle(! this.in_edit_mode); - this.$buttons.filter('div.o_edit_mode_available').toggle(config.debug && this.edit_mode_available && ! this.in_edit_mode); }, /** @@ -128,18 +96,6 @@ var ReportAction = AbstractAction.extend({ } switch(message) { - case 'report.editor:save_ok': - // Reload the iframe in order to disable the editor. - this.iframe.src = this.report_url; - this.in_edit_mode = false; - this._update_control_panel_buttons(); - break; - case 'report.editor:discard_ok': - // Reload the iframe in order to disable the editor. - this.iframe.src = this.report_url; - this.in_edit_mode = false; - this._update_control_panel_buttons(); - break; case 'report:do_action': return this.do_action(ev.originalEvent.data.action); default: @@ -157,25 +113,6 @@ var ReportAction = AbstractAction.extend({ this.iframe.contentWindow.postMessage(message, this.trusted_origin); }, - on_click_edit: function () { - // We reload the iframe with a special query string to enable the editor. - if (this.report_url.indexOf('?') === -1) { - this.iframe.src = this.report_url + '?enable_editor=1'; - } else { - this.iframe.src = this.report_url + '&enable_editor=1'; - } - this.in_edit_mode = true; - this._update_control_panel_buttons(); - }, - - on_click_discard: function () { - this._post_message('report.editor:ask_discard'); - }, - - on_click_save: function () { - this._post_message('report.editor:ask_save'); - }, - on_click_print: function () { var action = { 'type': 'ir.actions.report', diff --git a/addons/web/static/src/js/report/report.editor.js b/addons/web/static/src/js/report/report.editor.js deleted file mode 100644 index 4955417a759..00000000000 --- a/addons/web/static/src/js/report/report.editor.js +++ /dev/null @@ -1,81 +0,0 @@ -odoo.define('report.editor', function (require) { -'use strict'; - -require('web.dom_ready') -var core = require('web.core'); -var utils = require('report.utils'); -var editor = require('web_editor.editor'); -var options = require('web_editor.snippets.options'); - -var web_base_url = $('html').attr('web-base-url'); -var trusted_host = utils.get_host_from_url(web_base_url); -var trusted_protocol = utils.get_protocol_from_url(web_base_url); -var trusted_origin = utils.build_origin(trusted_protocol, trusted_host); - -// Patch the editor's behavior when it is launched inside an iframe. -if (window.self !== window.top) { - - // 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 () { - // Force to not reload - return this._super(false).then(function () { - window.parent.postMessage('report.editor:save_ok', trusted_origin); - }); - }, - cancel: function () { - // Force to not reload - return this._super(false).then(function () { - window.parent.postMessage('report.editor:discard_ok', trusted_origin); - }); - }, - }); - - // postMessage logic. - var AUTHORIZED_MESSAGES = [ - 'report.editor:ask_discard', - 'report.editor:ask_save', - ]; - - window.addEventListener('message', function (ev) { - // Check the origin of the received message. - var message_origin = utils.build_origin(ev.source.location.protocol, ev.source.location.host); - if (message_origin === trusted_origin) { - // Check the syntax of the received message. - var message = ev.data; - if (! _.isString(message) || (_.isString(message) && ! _.contains(AUTHORIZED_MESSAGES, message))) { - return; - } - - switch (message) { - case 'report.editor:ask_save': - core.bus.trigger('editor_save_request'); - break; - case 'report.editor:ask_discard': - core.bus.trigger('editor_discard_request'); - break; - default: - } - } - }, false); -} - -options.registry.many2one.include({ - _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/js/report/report.js b/addons/web/static/src/js/report/report.js index 50d78a9d151..9072966b19e 100644 --- a/addons/web/static/src/js/report/report.js +++ b/addons/web/static/src/js/report/report.js @@ -15,26 +15,26 @@ var trusted_host = utils.get_host_from_url(web_base_url); var trusted_protocol = utils.get_protocol_from_url(web_base_url); var trusted_origin = utils.build_origin(trusted_protocol, trusted_host); -// Allow to send commands to the webclient when the editor is disabled. -if (window.location.search.indexOf('enable_editor') === -1) { - // `do_action` command - $('[res-id][res-model][view-type]') - .wrap('<a/>') - .attr('href', '#') - .on('click', function (ev) { - ev.preventDefault(); - var action = { - 'type': 'ir.actions.act_window', - 'view_type': $(this).attr('view-type'), - 'view_mode': $(this).attr('view-mode') || $(this).attr('view-type'), - 'res_id': Number($(this).attr('res-id')), - 'res_model': $(this).attr('res-model'), - 'views': [[$(this).attr('view-id') || false, $(this).attr('view-type')]], - }; - window.parent.postMessage({ - 'message': 'report:do_action', - 'action': action, - }, trusted_origin); - }); -} +// Allow sending commands to the webclient +// `do_action` command +$('[res-id][res-model][view-type]') + .wrap('<a/>') + .attr('href', '#') + .on('click', function (ev) { + ev.preventDefault(); + var action = { + 'type': 'ir.actions.act_window', + 'view_type': $(this).attr('view-type'), + 'view_mode': $(this).attr('view-mode') || $(this).attr('view-type'), + 'res_id': Number($(this).attr('res-id')), + 'res_model': $(this).attr('res-model'), + 'views': [ + [$(this).attr('view-id') || false, $(this).attr('view-type')], + ], + }; + window.parent.postMessage({ + 'message': 'report:do_action', + 'action': action, + }, trusted_origin); + }); }); diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss index 81a59743788..27d0d13770e 100644 --- a/addons/web/static/src/scss/fields.scss +++ b/addons/web/static/src/scss/fields.scss @@ -33,7 +33,7 @@ } // Block fields - &.note-editor, &.oe_form_field_html, &.oe_form_field_html_text, &.oe_form_field_text, &.o_field_domain, &.o_graph_linechart, &.o_graph_barchart { + &.note-editor, &.oe_form_field_html, &.oe_form_field_text, &.o_field_domain, &.o_graph_linechart, &.o_graph_barchart { display: block; } diff --git a/addons/web/static/src/scss/report.editor.scss b/addons/web/static/src/scss/report.editor.scss deleted file mode 100644 index 544787ff6b2..00000000000 --- a/addons/web/static/src/scss/report.editor.scss +++ /dev/null @@ -1,26 +0,0 @@ -.o_editable { - &:not(:empty), &[data-oe-type] { - &[date-oe-model="ir.ui.view"] { - &, th, td { - &:hover { - -moz-box-shadow: #DDCC33 0 0 5px 2px inset; - -webkit-box-shadow: #DDCC33 0 0 5px 2px inset; - box-shadow: #DDCC33 0 0 5px 2px inset; - } - } - } - } -} - -body.o_in_iframe { - margin-top: 40px!important; - div#web_editor-top-edit { - > form { - display: none; - } - } - - .header, .footer { - display: none; - } -} diff --git a/addons/web/static/src/scss/report_backend.scss b/addons/web/static/src/scss/report_backend.scss index 62252c2d9f7..18ea620ce67 100644 --- a/addons/web/static/src/scss/report_backend.scss +++ b/addons/web/static/src/scss/report_backend.scss @@ -9,6 +9,6 @@ border: none; } -.o_report_no_edit_mode, .o_edit_mode_available, .o_report_edit_mode { +.o_report_buttons { display: inline-block; } diff --git a/addons/web/static/src/xml/colorpicker.xml b/addons/web/static/src/xml/colorpicker.xml deleted file mode 100644 index 4d85f3172a1..00000000000 --- a/addons/web/static/src/xml/colorpicker.xml +++ /dev/null @@ -1,62 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates> - <t t-name="web.colorpicker"> - <div class="o_colorpicker_widget"> - <div class="row"> - <div class="col-auto"> - <div class="o_color_pick_area d-inline-block border"> - <div class="o_picker_pointer rounded-circle"/> - </div> - <div class="o_color_slider d-inline-block ml-2 border"> - <div class="o_slider_pointer"/> - </div> - <div class="o_opacity_slider d-inline-block ml-2 border"> - <div class="o_opacity_pointer"/> - </div> - </div> - <form class="col o_color_picker_inputs"> - <div class="input-group"> - <div class="input-group-prepend o_color_preview_parent"> - <span class="input-group-text rounded-0 o_color_preview"/> - </div> - <input type="text" class="form-control o_hex_input" data-color-method="hex"/> - </div> - <div class="form-row mt-2"> - <div class="col"> - <div class="form-group"> - <label for="red">Red</label> - <input type="text" class="form-control o_red_input" data-color-method="rgb" id="red"/> - </div> - <div class="form-group"> - <label for="green">Green</label> - <input type="text" class="form-control o_green_input" data-color-method="rgb" id="green"/> - </div> - <div class="form-group"> - <label for="blue">Blue</label> - <input type="text" class="form-control o_blue_input" data-color-method="rgb" id="blue"/> - </div> - </div> - <div class="col"> - <div class="form-group"> - <label for="hue">Hue</label> - <input type="text" class="form-control o_hue_input" data-color-method="hsl" id="hue"/> - </div> - <div class="form-group"> - <label for="saturation">Saturation %</label> - <input type="text" class="form-control o_saturation_input" data-color-method="hsl" id="saturation"/> - </div> - <div class="form-group"> - <label for="lightness">Lightness %</label> - <input type="text" class="form-control o_lightness_input" data-color-method="hsl" id="lightness"/> - </div> - </div> - </div> - <div class="form-group"> - <label for="opacity">Opacity %</label> - <input type="text" class="form-control o_opacity_input" data-color-method="opacity" id="opacity"/> - </div> - </form> - </div> - </div> - </t> -</templates> diff --git a/addons/web/static/src/xml/report.xml b/addons/web/static/src/xml/report.xml index 49313e8de5a..7f08d009d53 100644 --- a/addons/web/static/src/xml/report.xml +++ b/addons/web/static/src/xml/report.xml @@ -7,15 +7,8 @@ <!-- Buttons of the Control Panel --> <t t-name="report.client_action.ControlButtons"> - <div class="o_report_no_edit_mode"> + <div class="o_report_buttons"> <button type="button" class="btn btn-primary o_report_print" title="Print">Print</button> </div> - <div class="o_edit_mode_available"> - <button type="button" class="btn btn-secondary o_report_edit" title="Edit">Edit</button> - </div> - <div class="o_report_edit_mode"> - <button type="button" class="btn btn-primary o_report_save" title="Save">Save</button> - <button type="button" class="btn btn-secondary o_report_discard" title="Discard">Discard</button> - </div> </t> -</template>
\ No newline at end of file +</template> diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js index 3eaaae0a46f..27dbaeeaa44 100644 --- a/addons/web/static/tests/helpers/test_utils_mock.js +++ b/addons/web/static/tests/helpers/test_utils_mock.js @@ -69,6 +69,7 @@ function removeSrcAttribute(el, widget) { if (widget) { widget._rpc({ route: src }); } + $(node).trigger('load'); } } } @@ -228,6 +229,7 @@ function addMockEnvironment(widget, params) { } $('body').off('DOMNodeInserted.removeSRC'); + $('body').removeClass('debug'); $('.blockUI').remove(); widgetDestroy.call(this); diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml index c9f78d20394..79799bddcc1 100644 --- a/addons/web/views/report_templates.xml +++ b/addons/web/views/report_templates.xml @@ -23,10 +23,9 @@ <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/layout_boxed.scss"/> <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/layout_clean.scss"/> + <script type="text/javascript" src="/web/static/src/js/chrome/public_root_widget.js"/> <script type="text/javascript" src="/web/static/src/js/services/session.js"/> <script type="text/javascript" src="/web/static/src/js/report/utils.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/content/body_manager.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/root_widget.js"/> <script type="text/javascript" src='/web/static/src/js/report/report.js' /> </template> @@ -34,23 +33,10 @@ <link href="/web/static/src/css/reset.min.css" rel="stylesheet"/> </template> - <template id="report_assets_editor"> - <t t-call="web._assets_helpers"/> - - <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/report.editor.scss"/> - - <script type="text/javascript" src="/web/static/src/js/services/session.js"/> - <script type="text/javascript" src='/web/static/src/js/report/report.editor.js' /> - </template> - <!-- Private templates used internally by reports --> <template id="report_layout" name="Report layout"><!DOCTYPE html> <html t-att-lang="lang and lang.replace('_', '-')" - t-att-data-editable="'1' if editable else None" - t-att-data-translatable="'1' if translatable else None" - t-att-data-edit_translations="'1' if edit_translations else None" - t-att-data-main-object="repr(main_object) if editable else None" t-att-data-report-margin-top="data_report_margin_top" t-att-data-report-header-spacing="data_report_header_spacing" t-att-data-report-dpi="data_report_dpi" @@ -60,27 +46,13 @@ <meta charset="utf-8"/> <meta name="viewport" content="initial-scale=1"/> <title><t t-esc="title or 'Odoo Report'"/></title> - <t t-call-assets="web.assets_common" t-js="false"/> <t t-call-assets="web.report_assets_common" t-js="false"/> - <t t-if="editable"> - <t t-call-assets="web_editor.summernote" t-js="false"/> - <t t-call-assets="web_editor.assets_editor" t-js="false"/> - <t t-call-assets="web.report_assets_editor" t-js="false"/> - </t> <t t-call-assets="web.assets_common" t-css="false"/> <t t-call-assets="web.report_assets_common" t-css="false"/> - <t t-if="editable"> - <t t-call-assets="web_editor.summernote" t-css="false"/> - <t t-call-assets="web_editor.assets_editor" t-css="false"/> - <t t-call-assets="web.report_assets_editor" t-css="false"/> - - <script type="text/javascript" src="/web_editor/static/src/js/iframe.js"></script> - <script t-if="enable_editor and inline_mode" type="text/javascript" src="/web_editor/static/src/js/inline.js"></script> - </t> </head> - <body> + <body t-att-class="'container' if not full_width else 'container-fluid'"> <div id="wrapwrap"> - <main t-att-class="'container' if not full_width else 'container-fluid'"> + <main> <t t-raw="0"/> </main> </div> diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 4fee1cd95c8..b4c0b3cbd53 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -136,7 +136,6 @@ <script type="text/javascript" src="/web/static/src/js/services/session_storage_service.js"></script> <script type="text/javascript" src="/web/static/src/js/core/ram_storage.js"></script> <script type="text/javascript" src="/web/static/src/js/widgets/rainbow_man.js"></script> - <script type="text/javascript" src="/web/static/src/js/widgets/colorpicker.js"/> </template> <template id="assets_backend" name="Backend Assets (used in backend interface)"> diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index 4b6cf3fcc8a..9a81fa20227 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -13,7 +13,6 @@ Odoo Web Editor widget. 'data': [ 'security/ir.model.access.csv', 'views/editor.xml', - 'views/iframe.xml', 'views/snippets.xml', ], 'qweb': [ diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 1b3f03966f3..bd267c5f32d 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -20,54 +20,6 @@ logger = logging.getLogger(__name__) class Web_Editor(http.Controller): #------------------------------------------------------ - # Backend snippet - #------------------------------------------------------ - @http.route('/web_editor/snippets', type='json', auth="user") - def snippets(self, **kwargs): - return request.env.ref('web_editor.snippets').render(None) - - #------------------------------------------------------ - # Backend html field - #------------------------------------------------------ - @http.route('/web_editor/field/html', type='http', auth="user") - def FieldTextHtml(self, model=None, res_id=None, field=None, callback=None, **kwargs): - kwargs.update( - model=model, - res_id=res_id, - field=field, - datarecord=json.loads(kwargs['datarecord']), - debug=request.debug) - - for k in kwargs: - if isinstance(kwargs[k], str) and kwargs[k].isdigit(): - kwargs[k] = int(kwargs[k]) - - trans = dict( - lang=kwargs.get('lang', request.env.context.get('lang')), - translatable=kwargs.get('translatable'), - edit_translations=kwargs.get('edit_translations'), - editable=kwargs.get('enable_editor')) - - kwargs.update(trans) - - record = None - if model and kwargs.get('res_id'): - record = request.env[model].with_context(trans).browse(kwargs.get('res_id')) - - kwargs.update(content=record and getattr(record, field) or "") - - return request.render(kwargs.get("template") or "web_editor.FieldTextHtml", kwargs, uid=request.uid) - - #------------------------------------------------------ - # Backend html field in inline mode - #------------------------------------------------------ - @http.route('/web_editor/field/html/inline', type='http', auth="user") - def FieldTextHtmlInline(self, model=None, res_id=None, field=None, callback=None, **kwargs): - kwargs['inline_mode'] = True - kwargs['dont_load_assets'] = not kwargs.get('enable_editor') and not kwargs.get('edit_translations') - return self.FieldTextHtml(model, res_id, field, callback, **kwargs) - - #------------------------------------------------------ # convert font into picture #------------------------------------------------------ @http.route([ @@ -137,6 +89,106 @@ class Web_Editor(http.Controller): return response #------------------------------------------------------ + # Update a checklist in the editor on check/uncheck + #------------------------------------------------------ + @http.route('/web_editor/checklist', type='json', auth='user') + def update_checklist(self, res_model, res_id, filename, checklistId, checked, **kwargs): + record = request.env[res_model].browse(res_id) + value = getattr(record, filename, False) + htmlelem = etree.fromstring("<div>%s</div>" % value, etree.HTMLParser()) + checked = bool(checked) + + li = htmlelem.find(".//li[@id='checklist-id-" + str(checklistId) + "']") + + if not self._update_checklist_recursive(li, checked, children=True, ancestors=True): + return value + + value = etree.tostring(htmlelem[0][0], encoding='utf-8', method='html')[5:-6] + record.write({filename: value}) + + return value + + def _update_checklist_recursive (self, li, checked, children=False, ancestors=False): + if 'checklist-id-' not in li.get('id', ''): + return False + + classname = li.get('class', '') + if ('o_checked' in classname) == checked: + return False + + # check / uncheck + if checked: + classname = '%s o_checked' % classname + else: + classname = re.sub(r"\s?o_checked\s?", '', classname) + li.set('class', classname) + + # propagate to children + if children: + node = li.getnext() + ul = None + if node is not None: + if node.tag == 'ul': + ul = node + if node.tag == 'li' and len(node.getchildren()) == 1 and node.getchildren()[0].tag == 'ul': + ul = node.getchildren()[0] + + if ul is not None: + for child in ul.getchildren(): + if child.tag == 'li': + self._update_checklist_recursive(child, checked, children=True) + + # propagate to ancestors + if ancestors: + allSelected = True + ul = li.getparent() + if ul.tag == 'li': + ul = ul.getparent() + + for child in ul.getchildren(): + if child.tag == 'li' and 'o_checked' not in child.get('class', ''): + allSelected = False + + node = ul.getprevious() + if node is not None and node.tag == 'li': + self._update_checklist_recursive(node, allSelected, ancestors=True) + + return True + + #------------------------------------------------------ + # upload an image as base64 + #------------------------------------------------------ + @http.route('/web_editor/add_image_base64', type='json', auth='user', methods=['POST'], website=True) + def add_image_base64(self, res_model, res_id, image_base64, filename, disable_optimization=None, **kwargs): + data = base64.b64decode(image_base64) + attachment = self._image_to_attachment(res_model, res_id, data, filename, filename, disable_optimization=disable_optimization) + return attachment.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token'])[0] + + def _image_to_attachment(self, res_model, res_id, data, name, datas_fname, disable_optimization=None): + Attachments = request.env['ir.attachment'] + try: + image = Image.open(io.BytesIO(data)) + w, h = image.size + if w*h > 42e6: # Nokia Lumia 1020 photo resolution + raise ValueError( + u"Image size excessive, uploaded images must be smaller " + u"than 42 million pixel") + if not disable_optimization and image.format in ('PNG', 'JPEG'): + data = tools.image_save_for_web(image) + except IOError: + pass + attachment = Attachments.create({ + 'name': name, + 'datas_fname': datas_fname, + 'datas': base64.b64encode(data), + 'public': res_model == 'ir.ui.view', + 'res_id': res_id, + 'res_model': res_model, + }) + attachment.generate_access_token() + return attachment + + #------------------------------------------------------ # add attachment (images or link) #------------------------------------------------------ @http.route('/web_editor/attachment/add', type='http', auth='user', methods=['POST'], website=True) @@ -175,32 +227,11 @@ class Web_Editor(http.Controller): attachments = request.env['ir.attachment'] for c_file in request.httprequest.files.getlist('upload'): data = c_file.read() - try: - image = Image.open(io.BytesIO(data)) - w, h = image.size - if w*h > 42e6: # Nokia Lumia 1020 photo resolution - raise ValueError( - u"Image size excessive, uploaded images must be smaller " - u"than 42 million pixel") - if not disable_optimization and image.format in ('PNG', 'JPEG'): - data = tools.image_save_for_web(image) - except IOError as e: - pass - name = c_file.filename datas_fname = name if filters: datas_fname = filters + '_' + datas_fname - attachment = Attachments.create({ - 'name': name, - 'datas': base64.b64encode(data), - 'datas_fname': datas_fname, - 'public': res_model == 'ir.ui.view', - 'res_id': res_id, - 'res_model': res_model, - }) - attachment.generate_access_token() - attachments += attachment + attachments += self._image_to_attachment(res_model, res_id, data, name, datas_fname, disable_optimization=disable_optimization) uploads += attachments.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token']) except Exception as e: logger.exception("Failed to upload image to attachment") diff --git a/addons/website/static/lib/jQuery.transfo.js b/addons/web_editor/static/lib/jQuery.transfo.js index 855d6f48560..855d6f48560 100644 --- a/addons/website/static/lib/jQuery.transfo.js +++ b/addons/web_editor/static/lib/jQuery.transfo.js diff --git a/addons/web_editor/static/lib/summernote/font/summernote.eot b/addons/web_editor/static/lib/summernote/font/summernote.eot Binary files differnew file mode 100644 index 00000000000..4b6f0f6b9fc --- /dev/null +++ b/addons/web_editor/static/lib/summernote/font/summernote.eot diff --git a/addons/web_editor/static/lib/summernote/font/summernote.ttf b/addons/web_editor/static/lib/summernote/font/summernote.ttf Binary files differnew file mode 100644 index 00000000000..514099b602e --- /dev/null +++ b/addons/web_editor/static/lib/summernote/font/summernote.ttf diff --git a/addons/web_editor/static/lib/summernote/font/summernote.woff b/addons/web_editor/static/lib/summernote/font/summernote.woff Binary files differnew file mode 100644 index 00000000000..63e49510b88 --- /dev/null +++ b/addons/web_editor/static/lib/summernote/font/summernote.woff diff --git a/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.css b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.css new file mode 100755 index 00000000000..6232dde991f --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.css @@ -0,0 +1,16 @@ +.ext-databasic { + position: relative; + display: block; + min-height: 50px; + background-color: cyan; + text-align: center; + padding: 20px; + border: 1px solid white; + border-radius: 10px; +} + +.ext-databasic p { + color: white; + font-size: 1.2em; + margin: 0; +} diff --git a/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.js b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.js new file mode 100755 index 00000000000..efe0d4dc33e --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.js @@ -0,0 +1,292 @@ +(function(factory) { + /* global define */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === 'object' && module.exports) { + // Node/CommonJS + module.exports = factory(require('jquery')); + } else { + // Browser globals + factory(window.jQuery); + } +}(function($) { + // pull in some summernote core functions + var ui = $.summernote.ui; + var dom = $.summernote.dom; + + // define the popover plugin + var DataBasicPlugin = function(context) { + var self = this; + var options = context.options; + var lang = options.langInfo; + + self.icon = '<i class="fa fa-object-group"/>'; + + // add context menu button for dialog + context.memo('button.databasic', function() { + return ui.button({ + contents: self.icon, + tooltip: lang.databasic.insert, + click: context.createInvokeHandler('databasic.showDialog') + }).render(); + }); + + // add popover edit button + context.memo('button.databasicDialog', function() { + return ui.button({ + contents: self.icon, + tooltip: lang.databasic.edit, + click: context.createInvokeHandler('databasic.showDialog') + }).render(); + }); + + // add popover size buttons + context.memo('button.databasicSize100', function() { + return ui.button({ + contents: '<span class="note-fontsize-10">100%</span>', + tooltip: lang.image.resizeFull, + click: context.createInvokeHandler('editor.resize', '1') + }).render(); + }); + context.memo('button.databasicSize50', function() { + return ui.button({ + contents: '<span class="note-fontsize-10">50%</span>', + tooltip: lang.image.resizeHalf, + click: context.createInvokeHandler('editor.resize', '0.5') + }).render(); + }); + context.memo('button.databasicSize25', function() { + return ui.button({ + contents: '<span class="note-fontsize-10">25%</span>', + tooltip: lang.image.resizeQuarter, + click: context.createInvokeHandler('editor.resize', '0.25') + }).render(); + }); + + self.events = { + 'summernote.init': function(we, e) { + // update existing containers + $('data.ext-databasic', e.editable).each(function() { self.setContent($(this)); }); + // TODO: make this an undo snapshot... + }, + 'summernote.keyup summernote.mouseup summernote.change summernote.scroll': function() { + self.update(); + }, + 'summernote.dialog.shown': function() { + self.hidePopover(); + } + }; + + self.initialize = function() { + // create dialog markup + var $container = options.dialogsInBody ? $(document.body) : context.layoutInfo.editor; + + var body = '<div class="form-group row-fluid">' + + '<label>' + lang.databasic.testLabel + '</label>' + + '<input class="ext-databasic-test form-control" type="text" />' + + '</div>'; + var footer = '<button href="#" class="btn btn-primary ext-databasic-save">' + lang.databasic.insert + '</button>'; + + self.$dialog = ui.dialog({ + title: lang.databasic.name, + fade: options.dialogsFade, + body: body, + footer: footer + }).render().appendTo($container); + + // create popover + self.$popover = ui.popover({ + className: 'ext-databasic-popover' + }).render().appendTo('body'); + var $content = self.$popover.find('.popover-content'); + + context.invoke('buttons.build', $content, options.popover.databasic); + }; + + self.destroy = function() { + self.$popover.remove(); + self.$popover = null; + self.$dialog.remove(); + self.$dialog = null; + }; + + self.update = function() { + // Prevent focusing on editable when invoke('code') is executed + if (!context.invoke('editor.hasFocus')) { + self.hidePopover(); + return; + } + + var rng = context.invoke('editor.createRange'); + var visible = false; + + if (rng.isOnData()) { + var $data = $(rng.sc).closest('data.ext-databasic'); + + if ($data.length) { + var pos = dom.posFromPlaceholder($data[0]); + + self.$popover.css({ + display: 'block', + left: pos.left, + top: pos.top + }); + + // save editor target to let size buttons resize the container + context.invoke('editor.saveTarget', $data[0]); + + visible = true; + } + } + + // hide if not visible + if (!visible) { + self.hidePopover(); + } + }; + + self.hidePopover = function() { + self.$popover.hide(); + }; + + // define plugin dialog + self.getInfo = function() { + var rng = context.invoke('editor.createRange'); + + if (rng.isOnData()) { + var $data = $(rng.sc).closest('data.ext-databasic'); + + if ($data.length) { + // Get the first node on range(for edit). + return { + node: $data, + test: $data.attr('data-test') + }; + } + } + + return {}; + }; + + self.setContent = function($node) { + $node.html('<p contenteditable="false">' + self.icon + ' ' + lang.databasic.name + ': ' + + $node.attr('data-test') + '</p>'); + }; + + self.updateNode = function(info) { + self.setContent(info.node + .attr('data-test', info.test)); + }; + + self.createNode = function(info) { + var $node = $('<data class="ext-databasic"></data>'); + + if ($node) { + // save node to info structure + info.node = $node; + // insert node into editor dom + context.invoke('editor.insertNode', $node[0]); + } + + return $node; + }; + + self.showDialog = function() { + var info = self.getInfo(); + var newNode = !info.node; + context.invoke('editor.saveRange'); + + self + .openDialog(info) + .then(function(dialogInfo) { + // [workaround] hide dialog before restore range for IE range focus + ui.hideDialog(self.$dialog); + context.invoke('editor.restoreRange'); + + // insert a new node + if (newNode) { + self.createNode(info); + } + + // update info with dialog info + $.extend(info, dialogInfo); + + self.updateNode(info); + }) + .fail(function() { + context.invoke('editor.restoreRange'); + }); + }; + + self.openDialog = function(info) { + return $.Deferred(function(deferred) { + var $inpTest = self.$dialog.find('.ext-databasic-test'); + var $saveBtn = self.$dialog.find('.ext-databasic-save'); + var onKeyup = function(event) { + if (event.keyCode === 13) { + $saveBtn.trigger('click'); + } + }; + + ui.onDialogShown(self.$dialog, function() { + context.triggerEvent('dialog.shown'); + + $inpTest.val(info.test).on('input', function() { + ui.toggleBtn($saveBtn, $inpTest.val()); + }).trigger('focus').on('keyup', onKeyup); + + $saveBtn + .text(info.node ? lang.databasic.edit : lang.databasic.insert) + .click(function(event) { + event.preventDefault(); + + deferred.resolve({ test: $inpTest.val() }); + }); + + // init save button + ui.toggleBtn($saveBtn, $inpTest.val()); + }); + + ui.onDialogHidden(self.$dialog, function() { + $inpTest.off('input keyup'); + $saveBtn.off('click'); + + if (deferred.state() === 'pending') { + deferred.reject(); + } + }); + + ui.showDialog(self.$dialog); + }); + }; + }; + + // Extends summernote + $.extend(true, $.summernote, { + plugins: { + databasic: DataBasicPlugin + }, + + options: { + popover: { + databasic: [ + ['databasic', ['databasicDialog', 'databasicSize100', 'databasicSize50', 'databasicSize25']] + ] + } + }, + + // add localization texts + lang: { + 'en-US': { + databasic: { + name: 'Basic Data Container', + insert: 'insert basic data container', + edit: 'edit basic data container', + testLabel: 'test input' + } + } + } + + }); +})); diff --git a/addons/web_editor/static/lib/summernote/plugin/hello/summernote-ext-hello.js b/addons/web_editor/static/lib/summernote/plugin/hello/summernote-ext-hello.js new file mode 100644 index 00000000000..22e4f6ac034 --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/hello/summernote-ext-hello.js @@ -0,0 +1,81 @@ +(function(factory) { + /* global define */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === 'object' && module.exports) { + // Node/CommonJS + module.exports = factory(require('jquery')); + } else { + // Browser globals + factory(window.jQuery); + } +}(function($) { + // Extends plugins for adding hello. + // - plugin is external module for customizing. + $.extend($.summernote.plugins, { + /** + * @param {Object} context - context object has status of editor. + */ + 'hello': function(context) { + var self = this; + + // ui has renders to build ui elements. + // - you can create a button with `ui.button` + var ui = $.summernote.ui; + + // add hello button + context.memo('button.hello', function() { + // create button + var button = ui.button({ + contents: '<i class="fa fa-child"/> Hello', + tooltip: 'hello', + click: function() { + self.$panel.show(); + self.$panel.hide(500); + // invoke insertText method with 'hello' on editor module. + context.invoke('editor.insertText', 'hello'); + } + }); + + // create jQuery object from button instance. + var $hello = button.render(); + return $hello; + }); + + // This events will be attached when editor is initialized. + this.events = { + // This will be called after modules are initialized. + 'summernote.init': function(we, e) { + console.log('summernote initialized', we, e); + }, + // This will be called when user releases a key on editable. + 'summernote.keyup': function(we, e) { + console.log('summernote keyup', we, e); + } + }; + + // This method will be called when editor is initialized by $('..').summernote(); + // You can create elements for plugin + this.initialize = function() { + this.$panel = $('<div class="hello-panel"/>').css({ + position: 'absolute', + width: 100, + height: 100, + left: '50%', + top: '50%', + background: 'red' + }).hide(); + + this.$panel.appendTo('body'); + }; + + // This methods will be called when editor is destroyed by $('..').summernote('destroy'); + // You should remove elements on `initialize`. + this.destroy = function() { + this.$panel.remove(); + this.$panel = null; + }; + } + }); +})); diff --git a/addons/web_editor/static/lib/summernote/plugin/specialchars/summernote-ext-specialchars.js b/addons/web_editor/static/lib/summernote/plugin/specialchars/summernote-ext-specialchars.js new file mode 100644 index 00000000000..2ee4e512efd --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/specialchars/summernote-ext-specialchars.js @@ -0,0 +1,312 @@ +(function(factory) { + /* global define */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === 'object' && module.exports) { + // Node/CommonJS + module.exports = factory(require('jquery')); + } else { + // Browser globals + factory(window.jQuery); + } +}(function($) { + $.extend($.summernote.plugins, { + 'specialchars': function(context) { + var self = this; + var ui = $.summernote.ui; + + var $editor = context.layoutInfo.editor; + var options = context.options; + var lang = options.langInfo; + + var KEY = { + UP: 38, + DOWN: 40, + LEFT: 37, + RIGHT: 39, + ENTER: 13 + }; + var COLUMN_LENGTH = 15; + var COLUMN_WIDTH = 35; + + var currentColumn = 0; + var currentRow = 0; + var totalColumn = 0; + var totalRow = 0; + + // special characters data set + var specialCharDataSet = [ + '"', '&', '<', '>', '¡', '¢', + '£', '¤', '¥', '¦', '§', + '¨', '©', 'ª', '«', '¬', + '®', '¯', '°', '±', '²', + '³', '´', 'µ', '¶', '·', + '¸', '¹', 'º', '»', '¼', + '½', '¾', '¿', '×', '÷', + 'ƒ', 'ˆ', '˜', '–', '—', + '‘', '’', '‚', '“', '”', + '„', '†', '‡', '•', '…', + '‰', '′', '″', '‹', '›', + '‾', '⁄', '€', 'ℑ', '℘', + 'ℜ', '™', 'ℵ', '←', '↑', + '→', '↓', '↔', '↵', '⇐', + '⇑', '⇒', '⇓', '⇔', '∀', + '∂', '∃', '∅', '∇', '∈', + '∉', '∋', '∏', '∑', '−', + '∗', '√', '∝', '∞', '∠', + '∧', '∨', '∩', '∪', '∫', + '∴', '∼', '≅', '≈', '≠', + '≡', '≤', '≥', '⊂', '⊃', + '⊄', '⊆', '⊇', '⊕', '⊗', + '⊥', '⋅', '⌈', '⌉', '⌊', + '⌋', '◊', '♠', '♣', '♥', + '♦' + ]; + + context.memo('button.specialchars', function() { + return ui.button({ + contents: '<i class="fa fa-font fa-flip-vertical">', + tooltip: lang.specialChar.specialChar, + click: function() { + self.show(); + } + }).render(); + }); + + /** + * Make Special Characters Table + * + * @member plugin.specialChar + * @private + * @return {jQuery} + */ + this.makeSpecialCharSetTable = function() { + var $table = $('<table/>'); + $.each(specialCharDataSet, function(idx, text) { + var $td = $('<td/>').addClass('note-specialchar-node'); + var $tr = (idx % COLUMN_LENGTH === 0) ? $('<tr/>') : $table.find('tr').last(); + + var $button = ui.button({ + callback: function($node) { + $node.html(text); + $node.attr('title', text); + $node.attr('data-value', encodeURIComponent(text)); + $node.css({ + width: COLUMN_WIDTH, + 'margin-right': '2px', + 'margin-bottom': '2px' + }); + } + }).render(); + + $td.append($button); + + $tr.append($td); + if (idx % COLUMN_LENGTH === 0) { + $table.append($tr); + } + }); + + totalRow = $table.find('tr').length; + totalColumn = COLUMN_LENGTH; + + return $table; + }; + + this.initialize = function() { + var $container = options.dialogsInBody ? $(document.body) : $editor; + + var body = '<div class="form-group row-fluid">' + this.makeSpecialCharSetTable()[0].outerHTML + '</div>'; + + this.$dialog = ui.dialog({ + title: lang.specialChar.select, + body: body + }).render().appendTo($container); + }; + + this.show = function() { + var text = context.invoke('editor.getSelectedText'); + context.invoke('editor.saveRange'); + this.showSpecialCharDialog(text).then(function(selectChar) { + context.invoke('editor.restoreRange'); + + // build node + var $node = $('<span></span>').html(selectChar)[0]; + + if ($node) { + // insert video node + context.invoke('editor.insertNode', $node); + } + }).fail(function() { + context.invoke('editor.restoreRange'); + }); + }; + + /** + * show image dialog + * + * @param {jQuery} $dialog + * @return {Promise} + */ + this.showSpecialCharDialog = function(text) { + return $.Deferred(function(deferred) { + var $specialCharDialog = self.$dialog; + var $specialCharNode = $specialCharDialog.find('.note-specialchar-node'); + var $selectedNode = null; + var ARROW_KEYS = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT]; + var ENTER_KEY = KEY.ENTER; + + function addActiveClass($target) { + if (!$target) { + return; + } + $target.find('button').addClass('active'); + $selectedNode = $target; + } + + function removeActiveClass($target) { + $target.find('button').removeClass('active'); + $selectedNode = null; + } + + // find next node + function findNextNode(row, column) { + var findNode = null; + $.each($specialCharNode, function(idx, $node) { + var findRow = Math.ceil((idx + 1) / COLUMN_LENGTH); + var findColumn = ((idx + 1) % COLUMN_LENGTH === 0) ? COLUMN_LENGTH : (idx + 1) % COLUMN_LENGTH; + if (findRow === row && findColumn === column) { + findNode = $node; + return false; + } + }); + return $(findNode); + } + + function arrowKeyHandler(keyCode) { + // left, right, up, down key + var $nextNode; + var lastRowColumnLength = $specialCharNode.length % totalColumn; + + if (KEY.LEFT === keyCode) { + if (currentColumn > 1) { + currentColumn = currentColumn - 1; + } else if (currentRow === 1 && currentColumn === 1) { + currentColumn = lastRowColumnLength; + currentRow = totalRow; + } else { + currentColumn = totalColumn; + currentRow = currentRow - 1; + } + } else if (KEY.RIGHT === keyCode) { + if (currentRow === totalRow && lastRowColumnLength === currentColumn) { + currentColumn = 1; + currentRow = 1; + } else if (currentColumn < totalColumn) { + currentColumn = currentColumn + 1; + } else { + currentColumn = 1; + currentRow = currentRow + 1; + } + } else if (KEY.UP === keyCode) { + if (currentRow === 1 && lastRowColumnLength < currentColumn) { + currentRow = totalRow - 1; + } else { + currentRow = currentRow - 1; + } + } else if (KEY.DOWN === keyCode) { + currentRow = currentRow + 1; + } + + if (currentRow === totalRow && currentColumn > lastRowColumnLength) { + currentRow = 1; + } else if (currentRow > totalRow) { + currentRow = 1; + } else if (currentRow < 1) { + currentRow = totalRow; + } + + $nextNode = findNextNode(currentRow, currentColumn); + + if ($nextNode) { + removeActiveClass($selectedNode); + addActiveClass($nextNode); + } + } + + function enterKeyHandler() { + if (!$selectedNode) { + return; + } + + deferred.resolve(decodeURIComponent($selectedNode.find('button').attr('data-value'))); + $specialCharDialog.modal('hide'); + } + + function keyDownEventHandler(event) { + event.preventDefault(); + var keyCode = event.keyCode; + if (keyCode === undefined || keyCode === null) { + return; + } + // check arrowKeys match + if (ARROW_KEYS.indexOf(keyCode) > -1) { + if ($selectedNode === null) { + addActiveClass($specialCharNode.eq(0)); + currentColumn = 1; + currentRow = 1; + return; + } + arrowKeyHandler(keyCode); + } else if (keyCode === ENTER_KEY) { + enterKeyHandler(); + } + return false; + } + + // remove class + removeActiveClass($specialCharNode); + + // find selected node + if (text) { + for (var i = 0; i < $specialCharNode.length; i++) { + var $checkNode = $($specialCharNode[i]); + if ($checkNode.text() === text) { + addActiveClass($checkNode); + currentRow = Math.ceil((i + 1) / COLUMN_LENGTH); + currentColumn = (i + 1) % COLUMN_LENGTH; + } + } + } + + ui.onDialogShown(self.$dialog, function() { + $(document).on('keydown', keyDownEventHandler); + + self.$dialog.find('button').tooltip(); + + $specialCharNode.on('click', function(event) { + event.preventDefault(); + deferred.resolve(decodeURIComponent($(event.currentTarget).find('button').attr('data-value'))); + ui.hideDialog(self.$dialog); + }); + }); + + ui.onDialogHidden(self.$dialog, function() { + $specialCharNode.off('click'); + + self.$dialog.find('button').tooltip('destroy'); + + $(document).off('keydown', keyDownEventHandler); + + if (deferred.state() === 'pending') { + deferred.reject(); + } + }); + + ui.showDialog(self.$dialog); + }); + }; + } + }); +})); diff --git a/addons/web_editor/static/lib/summernote/src/css/summernote.css b/addons/web_editor/static/lib/summernote/src/css/summernote.css deleted file mode 100644 index 7604c9272de..00000000000 --- a/addons/web_editor/static/lib/summernote/src/css/summernote.css +++ /dev/null @@ -1,447 +0,0 @@ -/* Theme Variables - ------------------------------------------*/ -/* Frame Mode Layout - ------------------------------------------*/ -.note-editor { - border: 1px solid #a9a9a9; - position: relative; - /* overflow: hidden; ODOO: removed for embedded editor */ - /* dropzone */ - /* codeview mode */ - /* fullscreen mode */ - /* statusbar */ -} -.note-editor .note-dropzone { - position: absolute; - display: none; - z-index: 100; - color: lightskyblue; - background-color: white; - opacity: 0.95; - pointer-event: none; -} -.note-editor .note-dropzone .note-dropzone-message { - display: table-cell; - vertical-align: middle; - text-align: center; - font-size: 28px; - font-weight: bold; -} -.note-editor .note-dropzone.hover { - color: #098ddf; -} -.note-editor.dragover .note-dropzone { - display: table; -} -.note-editor.codeview .note-editing-area .note-editable { - display: none; -} -.note-editor.codeview .note-editing-area .note-codable { - display: block; -} -.note-editor.fullscreen { - position: fixed; - top: 0; - left: 0; - width: 100%; - z-index: 1050; - /* bs3 modal-backdrop: 1030, bs2: 1040 */ -} -.note-editor.fullscreen .note-editable { - background-color: white; -} -.note-editor.fullscreen .note-resizebar { - display: none; -} -.note-editor .note-editing-area { - position: relative; - overflow: hidden; - /* editable */ - /* codeable */ -} -.note-editor .note-editing-area .note-editable { - background-color: #fff; - color: #000; - padding: 10px; - overflow: auto; - outline: none; -} -.note-editor .note-editing-area .note-editable[contenteditable=true]:empty:not(:focus):before { - content: attr(data-placeholder); -} -.note-editor .note-editing-area .note-editable[contenteditable="false"] { - background-color: #e5e5e5; -} -.note-editor .note-editing-area .note-codable { - display: none; - width: 100%; - padding: 10px; - border: none; - box-shadow: none; - font-family: Menlo, Monaco, monospace, sans-serif; - font-size: 14px; - color: #ccc; - background-color: #222; - resize: none; - /* override BS2 default style */ - box-sizing: border-box; - border-radius: 0; - margin-bottom: 0; -} -.note-editor .note-statusbar { - background-color: #f5f5f5; -} -.note-editor .note-statusbar .note-resizebar { - padding-top: 1px; - height: 8px; - width: 100%; - cursor: ns-resize; -} -.note-editor .note-statusbar .note-resizebar .note-icon-bar { - width: 20px; - margin: 1px auto; - border-top: 1px solid #a9a9a9; -} -/* Air Mode Layout -------------------------------------------*/ -.note-air-editor { - outline: none; -} -/* Popover -------------------------------------------*/ -.note-popover .popover { - max-width: none; -} -.note-popover .popover .popover-body a { - display: inline-block; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - /* for FF */ - vertical-align: middle; - /* for FF */ -} -.note-popover .popover .arrow { - left: 20px; -} -/* Popover and Toolbar (Button container) -------------------------------------------*/ -.note-popover .popover .popover-body, -.panel-heading.note-toolbar { - margin: 0; - padding: 0 0 5px 5px; - /* dropdown-menu for toolbar and popover */ - /* color palette for toolbar and popover */ -} -.note-popover .popover .popover-body > .btn-group, -.panel-heading.note-toolbar > .btn-group { - margin-top: 5px; - margin-left: 0; - margin-right: 5px; -} -.note-popover .popover .popover-body .btn-group .note-table, -.panel-heading.note-toolbar .btn-group .note-table { - min-width: 0; - padding: 5px; -} -.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker, -.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker { - font-size: 18px; -} -.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker .note-dimension-picker-mousecatcher, -.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-mousecatcher { - position: absolute !important; - z-index: 3; - width: 10em; - height: 10em; - cursor: pointer; -} -.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker .note-dimension-picker-unhighlighted, -.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-unhighlighted { - position: relative !important; - z-index: 1; - width: 5em; - height: 5em; - background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIj4+Pjp6ekKlAqjAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKhmnaJzPAAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat; -} -.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker .note-dimension-picker-highlighted, -.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-highlighted { - position: absolute !important; - z-index: 2; - width: 1em; - height: 1em; - background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIjd6vvD2f9LKLW+AAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKwNDEVT0AAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat; -} -.note-popover .popover .popover-body .note-style h1, -.panel-heading.note-toolbar .note-style h1, -.note-popover .popover .popover-body .note-style h2, -.panel-heading.note-toolbar .note-style h2, -.note-popover .popover .popover-body .note-style h3, -.panel-heading.note-toolbar .note-style h3, -.note-popover .popover .popover-body .note-style h4, -.panel-heading.note-toolbar .note-style h4, -.note-popover .popover .popover-body .note-style h5, -.panel-heading.note-toolbar .note-style h5, -.note-popover .popover .popover-body .note-style h6, -.panel-heading.note-toolbar .note-style h6, -.note-popover .popover .popover-body .note-style blockquote, -.panel-heading.note-toolbar .note-style blockquote { - margin: 0; -} -.note-popover .popover .popover-body .note-color .dropdown-toggle, -.panel-heading.note-toolbar .note-color .dropdown-toggle { - width: 20px; - padding-left: 5px; -} -.note-popover .popover .popover-body .note-color .dropdown-menu, -.panel-heading.note-toolbar .note-color .dropdown-menu { - min-width: 340px; -} -.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group, -.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group { - margin: 0; -} -.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group:first-child, -.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group:first-child { - margin: 0 5px; -} -.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-palette-title, -.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-palette-title { - font-size: 12px; - margin: 2px 7px; - text-align: center; - border-bottom: 1px solid #eee; -} -.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-color-reset, -.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-color-reset { - font-size: 11px; - margin: 3px; - padding: 0 3px; - cursor: pointer; - border-radius: 5px; -} -.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-color-row, -.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-color-row { - height: 20px; -} -.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-color-reset:hover, -.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-color-reset:hover { - background: #eee; -} -.note-popover .popover .popover-body .note-para .dropdown-menu, -.panel-heading.note-toolbar .note-para .dropdown-menu { - min-width: 216px; - padding: 5px; -} -.note-popover .popover .popover-body .note-para .dropdown-menu > div:first-child, -.panel-heading.note-toolbar .note-para .dropdown-menu > div:first-child { - margin-right: 5px; -} -.note-popover .popover .popover-body .dropdown-menu, -.panel-heading.note-toolbar .dropdown-menu { - min-width: 90px; - /* dropdown-menu right position */ - /* http://forrst.com/posts/Bootstrap_right_positioned_dropdown-2KB */ - /* dropdown-menu for selectbox */ -} -.note-popover .popover .popover-body .dropdown-menu.right, -.panel-heading.note-toolbar .dropdown-menu.right { - right: 0; - left: auto; -} -.note-popover .popover .popover-body .dropdown-menu.right::before, -.panel-heading.note-toolbar .dropdown-menu.right::before { - right: 9px; - left: auto !important; -} -.note-popover .popover .popover-body .dropdown-menu.right::after, -.panel-heading.note-toolbar .dropdown-menu.right::after { - right: 10px; - left: auto !important; -} -.note-popover .popover .popover-body .dropdown-menu.note-check li a i, -.panel-heading.note-toolbar .dropdown-menu.note-check li a i { - color: deepskyblue; - visibility: hidden; -} -.note-popover .popover .popover-body .dropdown-menu.note-check li a.checked i, -.panel-heading.note-toolbar .dropdown-menu.note-check li a.checked i { - visibility: visible; -} -.note-popover .popover .popover-body .note-fontsize-10, -.panel-heading.note-toolbar .note-fontsize-10 { - font-size: 10px; -} -.note-popover .popover .popover-body .note-color-palette, -.panel-heading.note-toolbar .note-color-palette { - line-height: 1; -} -.note-popover .popover .popover-body .note-color-palette div .note-color-btn, -.panel-heading.note-toolbar .note-color-palette div .note-color-btn { - width: 20px; - height: 20px; - padding: 0; - margin: 0; - border: 1px solid #fff; -} -.note-popover .popover .popover-body .note-color-palette div .note-color-btn:hover, -.panel-heading.note-toolbar .note-color-palette div .note-color-btn:hover { - border: 1px solid #000; -} -/* Dialog -------------------------------------------*/ -.note-dialog > div { - display: none; - /* BS2's hide pacth. */ -} -.note-dialog .form-group { - /* overwrite BS's form-horizontal minus margins */ - margin-left: 0; - margin-right: 0; -} -.note-dialog .note-modal-form { - margin: 0; - /* overwrite BS2's form margin bottom */ -} -.note-dialog .note-image-dialog .note-dropzone { - min-height: 100px; - font-size: 30px; - line-height: 4; - /* vertical-align */ - color: lightgray; - text-align: center; - border: 4px dashed lightgray; - margin-bottom: 10px; -} -.note-dialog .note-help-dialog { - font-size: 12px; - color: #ccc; - background-color: #222 !important; - opacity: 0.9; - /* BS2's background pacth. */ - background: transparent; - border: none; -} -.note-dialog .note-help-dialog .modal-content { - background: transparent; - border: 1px solid white; - box-shadow: none; - border-radius: 5px; -} -.note-dialog .note-help-dialog a { - font-size: 12px; - color: white; -} -.note-dialog .note-help-dialog .title { - color: white; - font-size: 14px; - font-weight: bold; - padding-bottom: 5px; - margin-bottom: 10px; - border-bottom: white 1px solid; -} -.note-dialog .note-help-dialog .modal-close { - font-size: 14px; - color: #dd0; - cursor: pointer; -} -.note-dialog .note-help-dialog .text-center { - margin: 10px 0 0; -} -.note-dialog .note-help-dialog .note-shortcut { - padding-top: 8px; - padding-bottom: 8px; -} -.note-dialog .note-help-dialog .note-shortcut-row { - margin-right: -5px; - margin-left: -5px; -} -.note-dialog .note-help-dialog .note-shortcut-col { - padding-right: 5px; - padding-left: 5px; -} -.note-dialog .note-help-dialog .note-shortcut-title { - font-size: 13px; - font-weight: bold; - color: #dd0; -} -.note-dialog .note-help-dialog .note-shortcut-key { - font-family: "Courier New"; - color: #dd0; - text-align: right; -} -/* Handle -------------------------------------------*/ -.note-handle { - /* control selection */ -} -.note-handle .note-control-selection { - position: absolute; - display: none; - border: 1px solid black; -} -.note-handle .note-control-selection > div { - position: absolute; -} -.note-handle .note-control-selection .note-control-selection-bg { - width: 100%; - height: 100%; - background-color: black; - opacity: 0.3; -} -.note-handle .note-control-selection .note-control-handle { - width: 7px; - height: 7px; - border: 1px solid black; -} -.note-handle .note-control-selection .note-control-holder { - width: 7px; - height: 7px; - border: 1px solid black; -} -.note-handle .note-control-selection .note-control-sizing { - width: 7px; - height: 7px; - border: 1px solid black; - background-color: white; -} -.note-handle .note-control-selection .note-control-nw { - top: -5px; - left: -5px; - border-right: none; - border-bottom: none; -} -.note-handle .note-control-selection .note-control-ne { - top: -5px; - right: -5px; - border-bottom: none; - border-left: none; -} -.note-handle .note-control-selection .note-control-sw { - bottom: -5px; - left: -5px; - border-top: none; - border-right: none; -} -.note-handle .note-control-selection .note-control-se { - right: -5px; - bottom: -5px; - cursor: se-resize; -} -.note-handle .note-control-selection .note-control-se.note-control-holder { - cursor: default; - border-top: none; - border-left: none; -} -.note-handle .note-control-selection .note-control-selection-info { - right: 0; - bottom: 0; - padding: 5px; - margin: 5px; - color: white; - background-color: black; - font-size: 12px; - border-radius: 5px; - opacity: 0.7; -} diff --git a/addons/web_editor/static/lib/summernote/src/js/EventHandler.js b/addons/web_editor/static/lib/summernote/src/js/EventHandler.js deleted file mode 100644 index a924e7def8a..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/EventHandler.js +++ /dev/null @@ -1,569 +0,0 @@ -define([ - 'summernote/core/agent', - 'summernote/core/func', - 'summernote/core/dom', - 'summernote/core/async', - 'summernote/core/key', - 'summernote/core/list', - 'summernote/editing/History', - 'summernote/module/Editor', - 'summernote/module/Toolbar', - 'summernote/module/Statusbar', - 'summernote/module/Popover', - 'summernote/module/Handle', - 'summernote/module/Fullscreen', - 'summernote/module/Codeview', - 'summernote/module/DragAndDrop', - 'summernote/module/Clipboard', - 'summernote/module/LinkDialog', - 'summernote/module/ImageDialog', - 'summernote/module/HelpDialog' -], function (agent, func, dom, async, key, list, History, - Editor, Toolbar, Statusbar, Popover, Handle, Fullscreen, Codeview, - DragAndDrop, Clipboard, LinkDialog, ImageDialog, HelpDialog) { - - /** - * @class EventHandler - * - * EventHandler - * - TODO: new instance per a editor - */ - var EventHandler = function () { - var self = this; - - /** - * Modules - */ - var modules = this.modules = { - editor: new Editor(this), - toolbar: new Toolbar(this), - statusbar: new Statusbar(this), - popover: new Popover(this), - handle: new Handle(this), - fullscreen: new Fullscreen(this), - codeview: new Codeview(this), - dragAndDrop: new DragAndDrop(this), - clipboard: new Clipboard(this), - linkDialog: new LinkDialog(this), - imageDialog: new ImageDialog(this), - helpDialog: new HelpDialog(this) - }; - - /** - * invoke module's method - * - * @param {String} moduleAndMethod - ex) 'editor.redo' - * @param {...*} arguments - arguments of method - * @return {*} - */ - this.invoke = function () { - var moduleAndMethod = list.head(list.from(arguments)); - var args = list.tail(list.from(arguments)); - - var splits = moduleAndMethod.split('.'); - var hasSeparator = splits.length > 1; - var moduleName = hasSeparator && list.head(splits); - var methodName = hasSeparator ? list.last(splits) : list.head(splits); - - var module = this.getModule(moduleName); - var method = module[methodName]; - - return method && method.apply(module, args); - }; - - /** - * returns module - * - * @param {String} moduleName - name of module - * @return {Module} - defaults is editor - */ - this.getModule = function (moduleName) { - return this.modules[moduleName] || this.modules.editor; - }; - - /** - * @param {jQuery} $holder - * @param {Object} callbacks - * @param {String} eventNamespace - * @returns {Function} - */ - var bindCustomEvent = this.bindCustomEvent = function ($holder, callbacks, eventNamespace) { - return function () { - var callback = callbacks[func.namespaceToCamel(eventNamespace, 'on')]; - if (callback) { - callback.apply($holder[0], arguments); - } - return $holder.trigger('summernote.' + eventNamespace, arguments); - }; - }; - - /** - * insert Images from file array. - * - * @private - * @param {Object} layoutInfo - * @param {File[]} files - */ - this.insertImages = function (layoutInfo, files) { - var $editor = layoutInfo.editor(), - $editable = layoutInfo.editable(), - $holder = layoutInfo.holder(); - - var callbacks = $editable.data('callbacks'); - var options = $editor.data('options'); - - // If onImageUpload options setted - if (callbacks.onImageUpload) { - bindCustomEvent($holder, callbacks, 'image.upload')(files); - // else insert Image as dataURL - } else { - $.each(files, function (idx, file) { - var filename = file.name; - if (options.maximumImageFileSize && options.maximumImageFileSize < file.size) { - bindCustomEvent($holder, callbacks, 'image.upload.error')(options.langInfo.image.maximumFileSizeError); - } else { - async.readFileAsDataURL(file).then(function (sDataURL) { - modules.editor.insertImage($editable, sDataURL, filename); - }).fail(function () { - bindCustomEvent($holder, callbacks, 'image.upload.error')(options.langInfo.image.maximumFileSizeError); - }); - } - }); - } - }; - - var commands = { - /** - * @param {Object} layoutInfo - */ - showLinkDialog: function (layoutInfo) { - modules.linkDialog.show(layoutInfo); - }, - - /** - * @param {Object} layoutInfo - */ - showImageDialog: function (layoutInfo) { - modules.imageDialog.show(layoutInfo); - }, - - /** - * @param {Object} layoutInfo - */ - showHelpDialog: function (layoutInfo) { - modules.helpDialog.show(layoutInfo); - }, - - /** - * @param {Object} layoutInfo - */ - fullscreen: function (layoutInfo) { - modules.fullscreen.toggle(layoutInfo); - }, - - /** - * @param {Object} layoutInfo - */ - codeview: function (layoutInfo) { - modules.codeview.toggle(layoutInfo); - } - }; - - var hMousedown = function (event) { - //preventDefault Selection for FF, IE8+ - if (dom.isImg(event.target)) { - event.preventDefault(); - } - }; - - var hKeyupAndMouseup = function (event) { - var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target); - modules.editor.removeBogus(layoutInfo.editable()); - hToolbarAndPopoverUpdate(event); - }; - - /** - * update sytle info - * @param {Object} styleInfo - * @param {Object} layoutInfo - */ - this.updateStyleInfo = function (styleInfo, layoutInfo) { - if (!styleInfo) { - return; - } - var isAirMode = (layoutInfo.editor().data('options') || {}).airMode; - if (!isAirMode) { - modules.toolbar.update(layoutInfo.toolbar(), styleInfo); - } - - modules.popover.update(layoutInfo.popover(), styleInfo, isAirMode); - modules.handle.update(layoutInfo.handle(), styleInfo, isAirMode); - }; - - var hToolbarAndPopoverUpdate = function (event) { - var target = event.target; - // delay for range after mouseup - setTimeout(function () { - var layoutInfo = dom.makeLayoutInfo(target); - /* ODOO: (start_modification */ - var $editable = layoutInfo.editable(); - if (event.setStyleInfoFromEditable) { - var styleInfo = modules.editor.styleFromNode($editable); - } else { - if (!event.isDefaultPrevented()) { - modules.editor.saveRange($editable); - } - var styleInfo = modules.editor.currentStyle(target); - } - /* ODOO: end_modification) */ - self.updateStyleInfo(styleInfo, layoutInfo); - }, 0); - }; - - var hScroll = function (event) { - var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target); - //hide popover and handle when scrolled - modules.popover.hide(layoutInfo.popover()); - modules.handle.hide(layoutInfo.handle()); - }; - - var hToolbarAndPopoverMousedown = function (event) { - // prevent default event when insertTable (FF, Webkit) - var $btn = $(event.target).closest('[data-event]'); - if ($btn.length) { - event.preventDefault(); - } - }; - - var hToolbarAndPopoverClick = function (event) { - var $btn = $(event.target).closest('[data-event]'); - - if (!$btn.length) { - return; - } - - var eventName = $btn.attr('data-event'), - value = $btn.attr('data-value'), - hide = $btn.attr('data-hide'); - - var layoutInfo = dom.makeLayoutInfo(event.target); - - // before command: detect control selection element($target) - var $target; - if ($.inArray(eventName, ['resize', 'floatMe', 'removeMedia', 'imageShape']) !== -1) { - var $selection = layoutInfo.handle().find('.note-control-selection'); - $target = $($selection.data('target')); - } - - // If requested, hide the popover when the button is clicked. - // Useful for things like showHelpDialog. - if (hide) { - $btn.parents('.popover').hide(); - } - - if ($.isFunction($.summernote.pluginEvents[eventName])) { - $.summernote.pluginEvents[eventName](event, modules.editor, layoutInfo, value); - } else if (modules.editor[eventName]) { // on command - var $editable = layoutInfo.editable(); - $editable.focus(); - modules.editor[eventName]($editable, value, $target); - event.preventDefault(); - } else if (commands[eventName]) { - commands[eventName].call(this, layoutInfo); - event.preventDefault(); - } - - // after command - if ($.inArray(eventName, ['backColor', 'foreColor']) !== -1) { - var options = layoutInfo.editor().data('options', options); - var module = options.airMode ? modules.popover : modules.toolbar; - module.updateRecentColor(list.head($btn), eventName, value); - } - - hToolbarAndPopoverUpdate(event); - }; - - var PX_PER_EM = 18; - var hDimensionPickerMove = function (event, options) { - var $picker = $(event.target.parentNode); // target is mousecatcher - var $dimensionDisplay = $picker.next(); - var $catcher = $picker.find('.note-dimension-picker-mousecatcher'); - var $highlighted = $picker.find('.note-dimension-picker-highlighted'); - var $unhighlighted = $picker.find('.note-dimension-picker-unhighlighted'); - - var posOffset; - // HTML5 with jQuery - e.offsetX is undefined in Firefox - if (event.offsetX === undefined) { - var posCatcher = $(event.target).offset(); - posOffset = { - x: event.pageX - posCatcher.left, - y: event.pageY - posCatcher.top - }; - } else { - posOffset = { - x: event.offsetX, - y: event.offsetY - }; - } - - var dim = { - c: Math.ceil(posOffset.x / PX_PER_EM) || 1, - r: Math.ceil(posOffset.y / PX_PER_EM) || 1 - }; - - $highlighted.css({ width: dim.c + 'em', height: dim.r + 'em' }); - $catcher.attr('data-value', dim.c + 'x' + dim.r); - - if (3 < dim.c && dim.c < options.insertTableMaxSize.col) { - $unhighlighted.css({ width: dim.c + 1 + 'em'}); - } - - if (3 < dim.r && dim.r < options.insertTableMaxSize.row) { - $unhighlighted.css({ height: dim.r + 1 + 'em'}); - } - - $dimensionDisplay.html(dim.c + ' x ' + dim.r); - }; - - /** - * bind KeyMap on keydown - * - * @param {Object} layoutInfo - * @param {Object} keyMap - */ - this.bindKeyMap = function (layoutInfo, keyMap) { - var $editor = layoutInfo.editor(); - var $editable = layoutInfo.editable(); - - $editable.on('keydown', function (event) { - var keys = []; - - // modifier - if (event.metaKey) { keys.push('CMD'); } - if (event.ctrlKey && !event.altKey) { keys.push('CTRL'); } - if (event.shiftKey) { keys.push('SHIFT'); } - - // keycode - var keyName = key.nameFromCode[event.keyCode]; - if (keyName) { - keys.push(keyName); - } - - var pluginEvent; - var keyString = keys.join('+'); - var eventName = keyMap[keyString]; - - // ODOO: (start_modification - // odoo change: add visible event to overwrite the browser comportment - var keycode = event.keyCode; - if (!eventName && - !event.ctrlKey && !event.metaKey && ( // special code/command - (keycode > 47 && keycode < 58) || // number keys - keycode == 32 || keycode == 13 || // spacebar & return - (keycode > 64 && keycode < 91) || // letter keys - (keycode > 95 && keycode < 112) || // numpad keys - (keycode > 185 && keycode < 193) || // ;=,-./` (in order) - (keycode > 218 && keycode < 223))) { // [\]' (in order)) - eventName = 'visible'; - } else if (!keycode && event.key !== 'Dead') { - self.invoke('restoreRange', $editable); - } - // ODOO: end_modification) - - if (eventName) { - // FIXME Summernote doesn't support event pipeline yet. - // - Plugin -> Base Code - pluginEvent = $.summernote.pluginEvents[keyString]; - if ($.isFunction(pluginEvent)) { - if (pluginEvent(event, modules.editor, layoutInfo)) { - return false; - } - } - - pluginEvent = $.summernote.pluginEvents[eventName]; - - if ($.isFunction(pluginEvent)) { - pluginEvent(event, modules.editor, layoutInfo); - } else if (modules.editor[eventName]) { - modules.editor[eventName]($editable, $editor.data('options')); - event.preventDefault(); - } else if (commands[eventName]) { - commands[eventName].call(this, layoutInfo); - event.preventDefault(); - } - } else if (key.isEdit(event.keyCode)) { - modules.editor.afterCommand($editable); - } - }); - }; - - /** - * attach eventhandler - * - * @param {Object} layoutInfo - layout Informations - * @param {Object} options - user options include custom event handlers - */ - this.attach = function (layoutInfo, options) { - // handlers for editable - if (options.shortcuts) { - this.bindKeyMap(layoutInfo, options.keyMap[agent.isMac ? 'mac' : 'pc']); - } - layoutInfo.editable().on('mousedown', hMousedown); - layoutInfo.editable().on('keyup mouseup', hKeyupAndMouseup); - layoutInfo.editable().on('scroll', hScroll); - - // handler for clipboard - modules.clipboard.attach(layoutInfo, options); - - // handler for handle and popover - modules.handle.attach(layoutInfo, options); - layoutInfo.popover().on('click', hToolbarAndPopoverClick); - layoutInfo.popover().on('mousedown', hToolbarAndPopoverMousedown); - - // handler for drag and drop - modules.dragAndDrop.attach(layoutInfo, options); - - // handlers for frame mode (toolbar, statusbar) - if (!options.airMode) { - // handler for toolbar - layoutInfo.toolbar().on('click', hToolbarAndPopoverClick); - layoutInfo.toolbar().on('mousedown', hToolbarAndPopoverMousedown); - - // handler for statusbar - modules.statusbar.attach(layoutInfo, options); - } - - // handler for table dimension - var $catcherContainer = options.airMode ? layoutInfo.popover() : - layoutInfo.toolbar(); - var $catcher = $catcherContainer.find('.note-dimension-picker-mousecatcher'); - $catcher.css({ - width: options.insertTableMaxSize.col + 'em', - height: options.insertTableMaxSize.row + 'em' - }).on('mousemove', function (event) { - hDimensionPickerMove(event, options); - }); - - // save options on editor - layoutInfo.editor().data('options', options); - - // ret styleWithCSS for backColor / foreColor clearing with 'inherit'. - if (!agent.isMSIE) { - // [workaround] for Firefox - // - protect FF Error: NS_ERROR_FAILURE: Failure - setTimeout(function () { - document.execCommand('styleWithCSS', 0, options.styleWithSpan); - }, 0); - } - - // History - var history = new History(layoutInfo.editable()); - layoutInfo.editable().data('NoteHistory', history); - - // All editor status will be saved on editable with jquery's data - // for support multiple editor with singleton object. - layoutInfo.editable().data('callbacks', { - onInit: options.onInit, - onFocus: options.onFocus, - onBlur: options.onBlur, - onKeydown: options.onKeydown, - onKeyup: options.onKeyup, - onMousedown: options.onMousedown, - onEnter: options.onEnter, - onPaste: options.onPaste, - onBeforeCommand: options.onBeforeCommand, - onChange: options.onChange, - onImageUpload: options.onImageUpload, - onImageUploadError: options.onImageUploadError, - onMediaDelete: options.onMediaDelete, - onToolbarClick: options.onToolbarClick, - onUpload: options.onUpload, - }); - - var styleInfo = modules.editor.styleFromNode(layoutInfo.editable()); - this.updateStyleInfo(styleInfo, layoutInfo); - }; - - /** - * attach jquery custom event - * - * @param {Object} layoutInfo - layout Informations - */ - this.attachCustomEvent = function (layoutInfo, options) { - var $holder = layoutInfo.holder(); - var $editable = layoutInfo.editable(); - var callbacks = $editable.data('callbacks'); - - $editable.focus(bindCustomEvent($holder, callbacks, 'focus')); - $editable.blur(bindCustomEvent($holder, callbacks, 'blur')); - - $editable.keydown(function (event) { - if (event.keyCode === key.code.ENTER) { - bindCustomEvent($holder, callbacks, 'enter').call(this, event); - } - bindCustomEvent($holder, callbacks, 'keydown').call(this, event); - }); - $editable.keyup(bindCustomEvent($holder, callbacks, 'keyup')); - - $editable.on('mousedown', bindCustomEvent($holder, callbacks, 'mousedown')); - $editable.on('mouseup', bindCustomEvent($holder, callbacks, 'mouseup')); - $editable.on('scroll', bindCustomEvent($holder, callbacks, 'scroll')); - - $editable.on('paste', bindCustomEvent($holder, callbacks, 'paste')); - - // [workaround] IE doesn't have input events for contentEditable - // - see: https://goo.gl/4bfIvA - var changeEventName = agent.isMSIE ? 'DOMCharacterDataModified DOMSubtreeModified DOMNodeInserted' : 'input'; - $editable.on(changeEventName, function () { - bindCustomEvent($holder, callbacks, 'change')($editable.html(), $editable); - }); - - if (!options.airMode) { - layoutInfo.toolbar().click(bindCustomEvent($holder, callbacks, 'toolbar.click')); - layoutInfo.popover().click(bindCustomEvent($holder, callbacks, 'popover.click')); - } - - // Textarea: auto filling the code before form submit. - if (dom.isTextarea(list.head($holder))) { - $holder.closest('form').submit(function (e) { - layoutInfo.holder().val(layoutInfo.holder().code()); - bindCustomEvent($holder, callbacks, 'submit').call(this, e, $holder.code()); - }); - } - - // textarea auto sync - if (dom.isTextarea(list.head($holder)) && options.textareaAutoSync) { - $holder.on('summernote.change', function () { - layoutInfo.holder().val(layoutInfo.holder().code()); - }); - } - - // fire init event - bindCustomEvent($holder, callbacks, 'init')(layoutInfo); - - // fire plugin init event - for (var i = 0, len = $.summernote.plugins.length; i < len; i++) { - if ($.isFunction($.summernote.plugins[i].init)) { - $.summernote.plugins[i].init(layoutInfo); - } - } - }; - - this.detach = function (layoutInfo, options) { - layoutInfo.holder().off(); - layoutInfo.editable().off(); - - layoutInfo.popover().off(); - layoutInfo.handle().off(); - layoutInfo.dialog().off(); - - if (!options.airMode) { - layoutInfo.dropzone().off(); - layoutInfo.toolbar().off(); - layoutInfo.statusbar().off(); - } - }; - }; - - return EventHandler; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/Renderer.js b/addons/web_editor/static/lib/summernote/src/js/Renderer.js deleted file mode 100644 index a57dab67573..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/Renderer.js +++ /dev/null @@ -1,1026 +0,0 @@ -define([ - 'summernote/core/agent', - 'summernote/core/dom', - 'summernote/core/func', - 'summernote/core/list' -], function (agent, dom, func, list) { - /** - * @class Renderer - * - * renderer - * - * rendering toolbar and editable - */ - var Renderer = function () { - - /** - * bootstrap button template - * @private - * @param {String} label button name - * @param {Object} [options] button options - * @param {String} [options.event] data-event - * @param {String} [options.className] button's class name - * @param {String} [options.value] data-value - * @param {String} [options.title] button's title for popup - * @param {String} [options.dropdown] dropdown html - * @param {String} [options.hide] data-hide - */ - var tplButton = function (label, options) { - var event = options.event; - var value = options.value; - var title = options.title; - var className = options.className; - var dropdown = options.dropdown; - var hide = options.hide; - - return (dropdown ? '<div class="btn-group' + - (className ? ' ' + className : '') + '">' : '') + - '<button type="button"' + - ' class="btn btn-secondary btn-sm' + - ((!dropdown && className) ? ' ' + className : '') + - (dropdown ? ' dropdown-toggle' : '') + - '"' + - (dropdown ? ' data-toggle="dropdown"' : '') + - (title ? ' title="' + title + '"' : '') + - (event ? ' data-event="' + event + '"' : '') + - (value ? ' data-value=\'' + value + '\'' : '') + - (hide ? ' data-hide=\'' + hide + '\'' : '') + - ' tabindex="-1">' + - label + - (dropdown ? ' <span class="caret"></span>' : '') + - '</button>' + - (dropdown || '') + - (dropdown ? '</div>' : ''); - }; - - /** - * bootstrap icon button template - * @private - * @param {String} iconClassName - * @param {Object} [options] - * @param {String} [options.event] - * @param {String} [options.value] - * @param {String} [options.title] - * @param {String} [options.dropdown] - */ - var tplIconButton = function (iconClassName, options) { - var label = '<i class="' + iconClassName + '"></i>'; - return tplButton(label, options); - }; - - /** - * bootstrap popover template - * @private - * @param {String} className - * @param {String} content - */ - var tplPopover = function (className, content) { - var $popover = $('<div class="' + className + ' popover bottom in" style="display: none;">' + - '<div class="arrow"></div>' + - '<div class="popover-body">' + - '</div>' + - '</div>'); - - $popover.find('.popover-body').append(content); - return $popover; - }; - - /** - * bootstrap dialog template - * - * @param {String} className - * @param {String} [title=''] - * @param {String} body - * @param {String} [footer=''] - */ - var tplDialog = function (className, title, body, footer) { - return '<div class="' + className + ' modal" role="dialog" aria-hidden="false">' + - '<div class="modal-dialog">' + - '<div class="modal-content">' + - (title ? - '<header class="modal-header">' + - '<h4 class="modal-title">' + title + '</h4>' + - '<button type="button" class="close" aria-hidden="true" tabindex="-1">×</button>' + - '</header>' : '' - ) + - '<main class="modal-body">' + body + '</main>' + - (footer ? - '<header class="modal-footer">' + footer + '</header>' : '' - ) + - '</div>' + - '</div>' + - '</div>'; - }; - - /** - * bootstrap dropdown template - * - * @param {String|String[]} contents - * @param {String} [className=''] - * @param {String} [nodeName=''] - */ - var tplDropdown = function (contents, className, nodeName) { - var classes = 'dropdown-menu' + (className ? ' ' + className : ''); - nodeName = nodeName || 'ul'; - if (contents instanceof Array) { - contents = contents.join(''); - } - - return '<' + nodeName + ' class="' + classes + '">' + contents + '</' + nodeName + '>'; - }; - - var tplButtonInfo = { - picture: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.image.image, { - event: 'showImageDialog', - title: lang.image.image, - hide: true - }); - }, - link: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.link.link, { - event: 'showLinkDialog', - title: lang.link.link, - hide: true - }); - }, - table: function (lang, options) { - var dropdown = [ - '<div class="note-dimension-picker">', - '<div class="note-dimension-picker-mousecatcher" data-event="insertTable" data-value="1x1"></div>', - '<div class="note-dimension-picker-highlighted"></div>', - '<div class="note-dimension-picker-unhighlighted"></div>', - '</div>', - '<div class="note-dimension-display"> 1 x 1 </div>' - ]; - - return tplIconButton(options.iconPrefix + options.icons.table.table, { - title: lang.table.table, - dropdown: tplDropdown(dropdown, 'note-table') - }); - }, - style: function (lang, options) { - var items = options.styleTags.reduce(function (memo, v) { - var label = lang.style[v === 'p' ? 'normal' : v]; - return memo + '<li><a class="dropdown-item" data-event="formatBlock" href="#" data-value="' + v + '">' + - ( - (v === 'p' || v === 'pre') ? label : - '<' + v + '>' + label + '</' + v + '>' - ) + - '</a></li>'; - }, ''); - - return tplIconButton(options.iconPrefix + options.icons.style.style, { - title: lang.style.style, - dropdown: tplDropdown(items) - }); - }, - fontname: function (lang, options) { - var realFontList = []; - var items = options.fontNames.reduce(function (memo, v) { - if (!agent.isFontInstalled(v) && !list.contains(options.fontNamesIgnoreCheck, v)) { - return memo; - } - realFontList.push(v); - return memo + '<li><a data-event="fontName" href="#" data-value="' + v + '" style="font-family:\'' + v + '\'">' + - '<i class="' + options.iconPrefix + options.icons.misc.check + '"></i> ' + v + - '</a></li>'; - }, ''); - - var hasDefaultFont = agent.isFontInstalled(options.defaultFontName); - var defaultFontName = (hasDefaultFont) ? options.defaultFontName : realFontList[0]; - - var label = '<span class="note-current-fontname">' + - defaultFontName + - '</span>'; - return tplButton(label, { - title: lang.font.name, - className: 'note-fontname', - dropdown: tplDropdown(items, 'note-check') - }); - }, - fontsize: function (lang, options) { - var items = options.fontSizes.reduce(function (memo, v) { - return memo + '<li><a data-event="fontSize" href="#" data-value="' + v + '">' + - '<i class="' + options.iconPrefix + options.icons.misc.check + '"></i> ' + v + - '</a></li>'; - }, ''); - - var label = '<span class="note-current-fontsize">11</span>'; - return tplButton(label, { - title: lang.font.size, - className: 'note-fontsize', - dropdown: tplDropdown(items, 'note-check') - }); - }, - color: function (lang, options) { - var colorButtonLabel = '<i class="' + - options.iconPrefix + options.icons.color.recent + - '" id="colors_preview" style="color:white;background-color:#B35E9B"></i>'; - var colorButton = tplButton(colorButtonLabel, { - className: 'note-recent-color', - title: lang.color.recent, - event: 'color', - value: '{"backColor":"#B35E9B"}' - }); - - var items = [ - '<li class="flex"><div class="btn-group flex-column">', - '<div class="note-palette-title">' + lang.color.background + '</div>', - '<div class="note-color-reset" data-event="backColor"', - ' data-value="inherit" title="' + lang.color.transparent + '">' + lang.color.setTransparent + '</div>', - '<div class="note-color-palette" data-target-event="backColor"></div>', - '</div><div class="btn-group flex-column">', - '<div class="note-palette-title">' + lang.color.foreground + '</div>', - '<div class="note-color-reset" data-event="foreColor" data-value="inherit" title="' + lang.color.reset + '">', - lang.color.resetToDefault, - '</div>', - '<div class="note-color-palette" data-target-event="foreColor"></div>', - '</div></li>' - ]; - - var moreButton = tplButton('', { - title: lang.color.more, - dropdown: tplDropdown(items) - }); - - return colorButton + moreButton; - }, - bold: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.font.bold, { - event: 'bold', - title: lang.font.bold - }); - }, - italic: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.font.italic, { - event: 'italic', - title: lang.font.italic - }); - }, - underline: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.font.underline, { - event: 'underline', - title: lang.font.underline - }); - }, - strikethrough: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.font.strikethrough, { - event: 'strikethrough', - title: lang.font.strikethrough - }); - }, - superscript: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.font.superscript, { - event: 'superscript', - title: lang.font.superscript - }); - }, - subscript: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.font.subscript, { - event: 'subscript', - title: lang.font.subscript - }); - }, - clear: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.font.clear, { - event: 'removeFormat', - title: lang.font.clear - }); - }, - ul: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.lists.unordered, { - event: 'insertUnorderedList', - title: lang.lists.unordered - }); - }, - ol: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.lists.ordered, { - event: 'insertOrderedList', - title: lang.lists.ordered - }); - }, - paragraph: function (lang, options) { - var leftButton = tplIconButton(options.iconPrefix + options.icons.paragraph.left, { - title: lang.paragraph.left, - event: 'justifyLeft' - }); - var centerButton = tplIconButton(options.iconPrefix + options.icons.paragraph.center, { - title: lang.paragraph.center, - event: 'justifyCenter' - }); - var rightButton = tplIconButton(options.iconPrefix + options.icons.paragraph.right, { - title: lang.paragraph.right, - event: 'justifyRight' - }); - var justifyButton = tplIconButton(options.iconPrefix + options.icons.paragraph.justify, { - title: lang.paragraph.justify, - event: 'justifyFull' - }); - - var outdentButton = tplIconButton(options.iconPrefix + options.icons.paragraph.outdent, { - title: lang.paragraph.outdent, - event: 'outdent' - }); - var indentButton = tplIconButton(options.iconPrefix + options.icons.paragraph.indent, { - title: lang.paragraph.indent, - event: 'indent' - }); - - var dropdown = [ - '<div class="note-align btn-group">', - leftButton + centerButton + rightButton + justifyButton, - '</div><div class="note-list btn-group">', - indentButton + outdentButton, - '</div>' - ]; - - return tplIconButton(options.iconPrefix + options.icons.paragraph.paragraph, { - title: lang.paragraph.paragraph, - dropdown: tplDropdown(dropdown, '', 'div') - }); - }, - height: function (lang, options) { - var items = options.lineHeights.reduce(function (memo, v) { - return memo + '<li><a data-event="lineHeight" href="#" data-value="' + parseFloat(v) + '">' + - '<i class="' + options.iconPrefix + options.icons.misc.check + '"></i> ' + v + - '</a></li>'; - }, ''); - - return tplIconButton(options.iconPrefix + options.icons.font.height, { - title: lang.font.height, - dropdown: tplDropdown(items, 'note-check') - }); - - }, - help: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.options.help, { - event: 'showHelpDialog', - title: lang.options.help, - hide: true - }); - }, - fullscreen: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.options.fullscreen, { - event: 'fullscreen', - title: lang.options.fullscreen - }); - }, - codeview: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.options.codeview, { - event: 'codeview', - title: lang.options.codeview - }); - }, - undo: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.history.undo, { - event: 'undo', - title: lang.history.undo - }); - }, - redo: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.history.redo, { - event: 'redo', - title: lang.history.redo - }); - }, - hr: function (lang, options) { - return tplIconButton(options.iconPrefix + options.icons.hr.insert, { - event: 'insertHorizontalRule', - title: lang.hr.insert - }); - } - }; - - var tplPopovers = function (lang, options) { - var tplLinkPopover = function () { - var linkButton = tplIconButton(options.iconPrefix + options.icons.link.edit, { - title: lang.link.edit, - event: 'showLinkDialog', - hide: true - }); - var unlinkButton = tplIconButton(options.iconPrefix + options.icons.link.unlink, { - title: lang.link.unlink, - event: 'unlink' - }); - var content = '<a href="http://www.google.com" target="_blank">www.google.com</a> ' + - '<div class="note-insert btn-group">' + - linkButton + unlinkButton + - '</div>'; - return tplPopover('note-link-popover', content); - }; - - var tplImagePopover = function () { - var autoButton = tplButton('<span class="note-fontsize-10">Auto</span>', { - event: 'resize', - value: 'auto' - }); - var fullButton = tplButton('<span class="note-fontsize-10">100%</span>', { - title: lang.image.resizeFull, - event: 'resize', - value: '1' - }); - var halfButton = tplButton('<span class="note-fontsize-10">50%</span>', { - title: lang.image.resizeHalf, - event: 'resize', - value: '0.5' - }); - var quarterButton = tplButton('<span class="note-fontsize-10">25%</span>', { - title: lang.image.resizeQuarter, - event: 'resize', - value: '0.25' - }); - - var leftButton = tplIconButton(options.iconPrefix + options.icons.image.floatLeft, { - title: lang.image.floatLeft, - event: 'floatMe', - value: 'left' - }); - var rightButton = tplIconButton(options.iconPrefix + options.icons.image.floatRight, { - title: lang.image.floatRight, - event: 'floatMe', - value: 'right' - }); - var justifyButton = tplIconButton(options.iconPrefix + options.icons.image.floatNone, { - title: lang.image.floatNone, - event: 'floatMe', - value: 'none' - }); - - var roundedButton = tplIconButton(options.iconPrefix + options.icons.image.shapeRounded, { - title: lang.image.shapeRounded, - event: 'imageShape', - value: 'rounded' - }); - var circleButton = tplIconButton(options.iconPrefix + options.icons.image.shapeCircle, { - title: lang.image.shapeCircle, - event: 'imageShape', - value: 'rounded-circle' - }); - var thumbnailButton = tplIconButton(options.iconPrefix + options.icons.image.shapeThumbnail, { - title: lang.image.shapeThumbnail, - event: 'imageShape', - value: 'img-thumbnail' - }); - var noneButton = tplIconButton(options.iconPrefix + options.icons.image.shapeNone, { - title: lang.image.shapeNone, - event: 'imageShape', - value: '' - }); - - var removeButton = tplIconButton(options.iconPrefix + options.icons.image.remove, { - title: lang.image.remove, - event: 'removeMedia', - value: 'none' - }); - - var content = (options.disableResizeImage ? '' : '<div class="btn-group">' + autoButton + fullButton + halfButton + quarterButton + '</div>') + - '<div class="btn-group">' + leftButton + rightButton + justifyButton + '</div>' + - '<div class="btn-group">' + roundedButton + circleButton + thumbnailButton + noneButton + '</div>' + - '<div class="btn-group">' + removeButton + '</div>'; - return tplPopover('note-image-popover', content); - }; - - var tplAirPopover = function () { - var $content = $('<div />'); - for (var idx = 0, len = options.airPopover.length; idx < len; idx ++) { - var group = options.airPopover[idx]; - - var $group = $('<div class="note-' + group[0] + ' btn-group">'); - for (var i = 0, lenGroup = group[1].length; i < lenGroup; i++) { - var $button = $(tplButtonInfo[group[1][i]](lang, options)); - - $button.attr('data-name', group[1][i]); - - $group.append($button); - } - $content.append($group); - } - - return tplPopover('note-air-popover', $content.children()); - }; - - var $notePopover = $('<div class="note-popover" />'); - - $notePopover.append(tplLinkPopover()); - $notePopover.append(tplImagePopover()); - - if (options.airMode) { - $notePopover.append(tplAirPopover()); - } - - return $notePopover; - }; - - this.tplButtonInfo = tplButtonInfo; // ODOO: allow access for override - this.tplPopovers = tplPopovers; // ODOO: allow access for override - - var tplHandles = function (options) { - return '<div class="note-handle">' + - '<div class="note-control-selection">' + - '<div class="note-control-selection-bg"></div>' + - '<div class="note-control-holder note-control-nw"></div>' + - '<div class="note-control-holder note-control-ne"></div>' + - '<div class="note-control-holder note-control-sw"></div>' + - '<div class="' + - (options.disableResizeImage ? 'note-control-holder' : 'note-control-sizing') + - ' note-control-se"></div>' + - (options.disableResizeImage ? '' : '<div class="note-control-selection-info"></div>') + - '</div>' + - '</div>'; - }; - - /** - * shortcut table template - * @param {String} title - * @param {String} body - */ - var tplShortcut = function (title, keys) { - var keyClass = 'note-shortcut-col col-6 note-shortcut-'; - var body = []; - - for (var i in keys) { - if (keys.hasOwnProperty(i)) { - body.push( - '<div class="' + keyClass + 'key">' + keys[i].kbd + '</div>' + - '<div class="' + keyClass + 'name">' + keys[i].text + '</div>' - ); - } - } - - return '<div class="note-shortcut-row row"><div class="' + keyClass + 'title offset-6">' + title + '</div></div>' + - '<div class="note-shortcut-row row">' + body.join('</div><div class="note-shortcut-row row">') + '</div>'; - }; - - var tplShortcutText = function (lang) { - var keys = [ - { kbd: '⌘ + B', text: lang.font.bold }, - { kbd: '⌘ + I', text: lang.font.italic }, - { kbd: '⌘ + U', text: lang.font.underline }, - { kbd: '⌘ + \\', text: lang.font.clear } - ]; - - return tplShortcut(lang.shortcut.textFormatting, keys); - }; - - var tplShortcutAction = function (lang) { - var keys = [ - { kbd: '⌘ + Z', text: lang.history.undo }, - { kbd: '⌘ + ⇧ + Z', text: lang.history.redo }, - { kbd: '⌘ + ]', text: lang.paragraph.indent }, - { kbd: '⌘ + [', text: lang.paragraph.outdent }, - { kbd: '⌘ + ENTER', text: lang.hr.insert } - ]; - - return tplShortcut(lang.shortcut.action, keys); - }; - - var tplShortcutPara = function (lang) { - var keys = [ - { kbd: '⌘ + ⇧ + L', text: lang.paragraph.left }, - { kbd: '⌘ + ⇧ + E', text: lang.paragraph.center }, - { kbd: '⌘ + ⇧ + R', text: lang.paragraph.right }, - { kbd: '⌘ + ⇧ + J', text: lang.paragraph.justify }, - { kbd: '⌘ + ⇧ + NUM7', text: lang.lists.ordered }, - { kbd: '⌘ + ⇧ + NUM8', text: lang.lists.unordered } - ]; - - return tplShortcut(lang.shortcut.paragraphFormatting, keys); - }; - - var tplShortcutStyle = function (lang) { - var keys = [ - { kbd: '⌘ + NUM0', text: lang.style.normal }, - { kbd: '⌘ + NUM1', text: lang.style.h1 }, - { kbd: '⌘ + NUM2', text: lang.style.h2 }, - { kbd: '⌘ + NUM3', text: lang.style.h3 }, - { kbd: '⌘ + NUM4', text: lang.style.h4 }, - { kbd: '⌘ + NUM5', text: lang.style.h5 }, - { kbd: '⌘ + NUM6', text: lang.style.h6 } - ]; - - return tplShortcut(lang.shortcut.documentStyle, keys); - }; - - var tplExtraShortcuts = function (lang, options) { - var extraKeys = options.extraKeys; - var keys = []; - - for (var key in extraKeys) { - if (extraKeys.hasOwnProperty(key)) { - keys.push({ kbd: key, text: extraKeys[key] }); - } - } - - return tplShortcut(lang.shortcut.extraKeys, keys); - }; - - var tplShortcutTable = function (lang, options) { - var colClass = 'class="note-shortcut note-shortcut-col col-md-6 col-12"'; - var template = [ - '<div ' + colClass + '>' + tplShortcutAction(lang, options) + '</div>' + - '<div ' + colClass + '>' + tplShortcutText(lang, options) + '</div>', - '<div ' + colClass + '>' + tplShortcutStyle(lang, options) + '</div>' + - '<div ' + colClass + '>' + tplShortcutPara(lang, options) + '</div>' - ]; - - if (options.extraKeys) { - template.push('<div ' + colClass + '>' + tplExtraShortcuts(lang, options) + '</div>'); - } - - return '<div class="note-shortcut-row row">' + - template.join('</div><div class="note-shortcut-row row">') + - '</div>'; - }; - - var replaceMacKeys = function (sHtml) { - return sHtml.replace(/⌘/g, 'Ctrl').replace(/⇧/g, 'Shift'); - }; - - var tplDialogInfo = { - image: function (lang, options) { - var imageLimitation = ''; - if (options.maximumImageFileSize) { - var unit = Math.floor(Math.log(options.maximumImageFileSize) / Math.log(1024)); - var readableSize = (options.maximumImageFileSize / Math.pow(1024, unit)).toFixed(2) * 1 + - ' ' + ' KMGTP'[unit] + 'B'; - imageLimitation = '<small>' + lang.image.maximumFileSize + ' : ' + readableSize + '</small>'; - } - - var body = '<div class="form-group row note-group-select-from-files">' + - '<label>' + lang.image.selectFromFiles + '</label>' + - '<input class="note-image-input form-control" type="file" name="files" accept="image/*" multiple="multiple" />' + - imageLimitation + - '</div>' + - '<div class="form-group row">' + - '<label>' + lang.image.url + '</label>' + - '<input class="note-image-url form-control col-md-12" type="text" />' + - '</div>'; - var footer = '<button href="#" class="btn btn-primary note-image-btn disabled" disabled>' + lang.image.insert + '</button>'; - return tplDialog('note-image-dialog', lang.image.insert, body, footer); - }, - - link: function (lang, options) { - var body = '<div class="form-group row">' + - '<label>' + lang.link.textToDisplay + '</label>' + - '<input class="note-link-text form-control col-md-12" type="text" />' + - '</div>' + - '<div class="form-group row">' + - '<label>' + lang.link.url + '</label>' + - '<input class="note-link-url form-control col-md-12" type="text" value="http://" />' + - '</div>' + - (!options.disableLinkTarget ? - '<div class="checkbox">' + - '<label>' + '<input type="checkbox" checked> ' + - lang.link.openInNewWindow + - '</label>' + - '</div>' : '' - ); - var footer = '<button href="#" class="btn btn-primary note-link-btn disabled" disabled>' + lang.link.insert + '</button>'; - return tplDialog('note-link-dialog', lang.link.insert, body, footer); - }, - - help: function (lang, options) { - var body = '<a class="modal-close float-right" aria-hidden="true" tabindex="-1">' + lang.shortcut.close + '</a>' + - '<div class="title">' + lang.shortcut.shortcuts + '</div>' + - (agent.isMac ? tplShortcutTable(lang, options) : replaceMacKeys(tplShortcutTable(lang, options))) + - '<p class="text-center">' + - '<a href="//summernote.org/" target="_blank">Summernote @VERSION</a> · ' + - '<a href="//github.com/summernote/summernote" target="_blank">Project</a> · ' + - '<a href="//github.com/summernote/summernote/issues" target="_blank">Issues</a>' + - '</p>'; - return tplDialog('note-help-dialog', '', body, ''); - } - }; - - var tplDialogs = function (lang, options) { - var dialogs = ''; - - $.each(tplDialogInfo, function (idx, tplDialog) { - dialogs += tplDialog(lang, options); - }); - - return '<div class="note-dialog">' + dialogs + '</div>'; - }; - - var tplStatusbar = function () { - return '<div class="note-resizebar">' + - '<div class="note-icon-bar"></div>' + - '<div class="note-icon-bar"></div>' + - '<div class="note-icon-bar"></div>' + - '</div>'; - }; - - var representShortcut = function (str) { - if (agent.isMac) { - str = str.replace('CMD', '⌘').replace('SHIFT', '⇧'); - } - - return str.replace('BACKSLASH', '\\') - .replace('SLASH', '/') - .replace('LEFTBRACKET', '[') - .replace('RIGHTBRACKET', ']'); - }; - - /** - * createTooltip - * - * @param {jQuery} $container - * @param {Object} keyMap - * @param {String} [sPlacement] - */ - var createTooltip = function ($container, keyMap, sPlacement) { - var invertedKeyMap = func.invertObject(keyMap); - var $buttons = $container.find('button'); - - $buttons.each(function (i, elBtn) { - var $btn = $(elBtn); - var sShortcut = invertedKeyMap[$btn.data('event')]; - if (sShortcut) { - $btn.attr('title', function (i, v) { - return v + ' (' + representShortcut(sShortcut) + ')'; - }); - } - // bootstrap tooltip on btn-group bug - // https://github.com/twbs/bootstrap/issues/5687 - }).tooltip({ - container: 'body', - trigger: 'hover', - placement: sPlacement || 'top' - }).on('click', function () { - $(this).tooltip('hide'); - }); - }; - - // createPalette - var createPalette = function ($container, options) { - var colorInfo = options.colors; - $container.find('.note-color-palette').each(function () { - var $palette = $(this), eventName = $palette.attr('data-target-event'); - var paletteContents = []; - for (var row = 0, lenRow = colorInfo.length; row < lenRow; row++) { - var colors = colorInfo[row]; - var buttons = []; - for (var col = 0, lenCol = colors.length; col < lenCol; col++) { - var color = colors[col]; - buttons.push(['<button type="button" class="note-color-btn" style="background-color:', color, - ';" data-event="', eventName, - '" data-value="', color, - '" title="', color, - '" data-toggle="button" tabindex="-1"></button>'].join('')); - } - paletteContents.push('<div class="note-color-row">' + buttons.join('') + '</div>'); - } - $palette.html(paletteContents.join('')); - }); - }; - - this.createPalette = createPalette; // ODOO: allow access for override - - /** - * create summernote layout (air mode) - * - * @param {jQuery} $holder - * @param {Object} options - */ - this.createLayoutByAirMode = function ($holder, options) { - var langInfo = options.langInfo; - var keyMap = options.keyMap[agent.isMac ? 'mac' : 'pc']; - var id = func.uniqueId(); - - $holder.addClass('note-air-editor note-editable'); // ODOO: removing panel-body class to remove unwanted style - $holder.attr({ - 'data-note-id': id, // ODOO: we use [data-note-id="{id}"] instead of [id="{id}"] - // 'id': 'note-editor-' + id, - 'contentEditable': true - }); - - var body = document.body; - var $container = $('#web_editor-toolbars') - - // create Popover - var $popover = $(this.tplPopovers(langInfo, options)); // ODOO: user (maybe) overrided method - $popover.addClass('note-air-layout'); - $popover.attr('id', 'note-popover-' + id); - $popover.appendTo($container); - createTooltip($popover, keyMap); - this.createPalette($popover, options); // ODOO: use (maybe) overrided method - - // create Handle - var $handle = $(tplHandles(options)); - $handle.addClass('note-air-layout'); - $handle.attr('id', 'note-handle-' + id); - $handle.appendTo($container); - - // create Dialog - var $dialog = $(tplDialogs(langInfo, options)); - $dialog.addClass('note-air-layout'); - $dialog.attr('id', 'note-dialog-' + id); - $dialog.find('button.close, a.modal-close').click(function () { - $(this).closest('.modal').modal('hide'); - }); - $dialog.appendTo($container); - }; - - /** - * create summernote layout (normal mode) - * - * @param {jQuery} $holder - * @param {Object} options - */ - this.createLayoutByFrame = function ($holder, options) { - var langInfo = options.langInfo; - - //01. create Editor - var $editor = $('<div class="note-editor panel panel-default" />'); - if (options.width) { - $editor.width(options.width); - } - - //02. statusbar (resizebar) - if (options.height > 0) { - $('<div class="note-statusbar">' + (options.disableResizeEditor ? '' : tplStatusbar()) + '</div>').prependTo($editor); - } - - //03 editing area - var $editingArea = $('<div class="note-editing-area" />'); - //03. create editable - var isContentEditable = !$holder.is(':disabled'); - var $editable = $('<div class="note-editable panel-body" contentEditable="' + isContentEditable + '"></div>').prependTo($editingArea); - - if (options.height) { - $editable.height(options.height); - } - if (options.direction) { - $editable.attr('dir', options.direction); - } - var placeholder = $holder.attr('placeholder') || options.placeholder; - if (placeholder) { - $editable.attr('data-placeholder', placeholder); - } - - $editable.html(dom.html($holder) || dom.emptyPara); - - //031. create codable - $('<textarea class="note-codable"></textarea>').prependTo($editingArea); - - //04. create Popover - var $popover = $(this.tplPopovers(langInfo, options)).prependTo($editingArea); // ODOO: use (maybe) overrided method - this.createPalette($popover, options); // ODOO: use (maybe) overrided method - createTooltip($popover, keyMap); - - //05. handle(control selection, ...) - $(tplHandles(options)).prependTo($editingArea); - - $editingArea.prependTo($editor); - - //06. create Toolbar - var $toolbar = $('<div class="note-toolbar panel-heading" />'); - for (var idx = 0, len = options.toolbar.length; idx < len; idx ++) { - var groupName = options.toolbar[idx][0]; - var groupButtons = options.toolbar[idx][1]; - - var $group = $('<div class="note-' + groupName + ' btn-group" />'); - for (var i = 0, btnLength = groupButtons.length; i < btnLength; i++) { - var buttonInfo = tplButtonInfo[groupButtons[i]]; - // continue creating toolbar even if a button doesn't exist - if (!$.isFunction(buttonInfo)) { continue; } - - var $button = $(buttonInfo(langInfo, options)); - $button.attr('data-name', groupButtons[i]); // set button's alias, becuase to get button element from $toolbar - $group.append($button); - } - $toolbar.append($group); - } - - var keyMap = options.keyMap[agent.isMac ? 'mac' : 'pc']; - this.createPalette($toolbar, options); // ODOO: use (maybe) overrided method - createTooltip($toolbar, keyMap, 'bottom'); - $toolbar.prependTo($editor); - - //07. create Dropzone - $('<div class="note-dropzone"><div class="note-dropzone-message"></div></div>').prependTo($editor); - - //08. create Dialog - var $dialogContainer = options.dialogsInBody ? $(document.body) : $editor; - var $dialog = $(tplDialogs(langInfo, options)).prependTo($dialogContainer); - $dialog.find('button.close, a.modal-close').click(function () { - $(this).closest('.modal').modal('hide'); - }); - - //09. Editor/Holder switch - $editor.insertAfter($holder); - $holder.hide(); - }; - - this.hasNoteEditor = function ($holder) { - return this.noteEditorFromHolder($holder).length > 0; - }; - - this.noteEditorFromHolder = function ($holder) { - if ($holder.hasClass('note-air-editor')) { - return $holder; - } else if ($holder.next().hasClass('note-editor')) { - return $holder.next(); - } else { - return $(); - } - }; - - /** - * create summernote layout - * - * @param {jQuery} $holder - * @param {Object} options - */ - this.createLayout = function ($holder, options) { - if (options.airMode) { - this.createLayoutByAirMode($holder, options); - } else { - this.createLayoutByFrame($holder, options); - } - }; - - /** - * returns layoutInfo from holder - * - * @param {jQuery} $holder - placeholder - * @return {Object} - */ - this.layoutInfoFromHolder = function ($holder) { - var $editor = this.noteEditorFromHolder($holder); - if (!$editor.length) { - return; - } - - // connect $holder to $editor - $editor.data('holder', $holder); - - return dom.buildLayoutInfo($editor); - }; - - /** - * removeLayout - * - * @param {jQuery} $holder - placeholder - * @param {Object} layoutInfo - * @param {Object} options - * - */ - this.removeLayout = function ($holder, layoutInfo, options) { - if (options.airMode) { - $holder.removeClass('note-air-editor note-editable') - .removeAttr('contentEditable'); // ODOO: removed id 'id contentEditable' - - layoutInfo.popover().remove(); - layoutInfo.handle().remove(); - layoutInfo.dialog().remove(); - } else { - $holder.html(layoutInfo.editable().html()); - - if (options.dialogsInBody) { - layoutInfo.dialog().remove(); - } - layoutInfo.editor().remove(); - $holder.show(); - } - }; - - /** - * - * @return {Object} - * @return {function(label, options=):string} return.button {@link #tplButton function to make text button} - * @return {function(iconClass, options=):string} return.iconButton {@link #tplIconButton function to make icon button} - * @return {function(className, title=, body=, footer=):string} return.dialog {@link #tplDialog function to make dialog} - */ - this.getTemplate = function () { - return { - button: tplButton, - iconButton: tplIconButton, - dialog: tplDialog, - dropdown: tplDropdown // ODOO: suggest upstream - }; - }; - - /** - * add button information - * - * @param {String} name button name - * @param {Function} buttonInfo function to make button, reference to {@link #tplButton},{@link #tplIconButton} - */ - this.addButtonInfo = function (name, buttonInfo) { - tplButtonInfo[name] = buttonInfo; - }; - - /** - * - * @param {String} name - * @param {Function} dialogInfo function to make dialog, reference to {@link #tplDialog} - */ - this.addDialogInfo = function (name, dialogInfo) { - tplDialogInfo[name] = dialogInfo; - }; - }; - - return Renderer; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/app.js b/addons/web_editor/static/lib/summernote/src/js/app.js deleted file mode 100644 index fba17afd588..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/app.js +++ /dev/null @@ -1,42 +0,0 @@ -require.config({ - baseUrl: 'src/js', - paths: { - jquery: '//code.jquery.com/jquery-1.11.3', - bootstrap: '//netdna.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min', - summernotevideo: '/../../plugin/summernote-ext-video', - CodeMirror: '//cdnjs.cloudflare.com/ajax/libs/codemirror/3.20.0/codemirror', - CodeMirrorXml: '//cdnjs.cloudflare.com/ajax/libs/codemirror/3.20.0/mode/xml/xml.min', - CodeMirrorFormatting: '//cdnjs.cloudflare.com/ajax/libs/codemirror/2.36.0/formatting.min' - }, - shim: { - bootstrap: ['jquery'], - CodeMirror: { exports: 'CodeMirror' }, - CodeMirrorXml: ['CodeMirror'], - CodeMirrorFormatting: ['CodeMirror', 'CodeMirrorXml'], - summernotevideo: ['summernote'] - }, - packages: [{ - name: 'summernote', - location: './', - main: 'summernote' - }] -}); - -require([ - 'jquery', 'bootstrap', 'CodeMirrorFormatting', - 'summernote', 'summernotevideo' -], function ($) { - // summernote - $('.summernote').summernote({ - height: 300, // set editable area's height - focus: true, // set focus editable area after summernote loaded - tabsize: 2, // size of tab - placeholder: 'Type your message here...', // set editable area's placeholder text - codemirror: { // code mirror options - mode: 'text/html', - htmlMode: true, - lineNumbers: true, - theme: 'monokai' - } - }); -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/core/agent.js b/addons/web_editor/static/lib/summernote/src/js/core/agent.js deleted file mode 100644 index 21555a1191c..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/core/agent.js +++ /dev/null @@ -1,170 +0,0 @@ -define(['jquery'], function ($) { - if (!Array.prototype.reduce) { - /** - * Array.prototype.reduce polyfill - * - * @param {Function} callback - * @param {Value} [initialValue] - * @return {Value} - * - * @see http://goo.gl/WNriQD - */ - Array.prototype.reduce = function (callback) { - var t = Object(this), len = t.length >>> 0, k = 0, value; - if (arguments.length === 2) { - value = arguments[1]; - } else { - while (k < len && !(k in t)) { - k++; - } - if (k >= len) { - throw new TypeError('Reduce of empty array with no initial value'); - } - value = t[k++]; - } - for (; k < len; k++) { - if (k in t) { - value = callback(value, t[k], k, t); - } - } - return value; - }; - } - - if ('function' !== typeof Array.prototype.filter) { - /** - * Array.prototype.filter polyfill - * - * @param {Function} func - * @return {Array} - * - * @see http://goo.gl/T1KFnq - */ - Array.prototype.filter = function (func) { - var t = Object(this), len = t.length >>> 0; - - var res = []; - var thisArg = arguments.length >= 2 ? arguments[1] : void 0; - for (var i = 0; i < len; i++) { - if (i in t) { - var val = t[i]; - if (func.call(thisArg, val, i, t)) { - res.push(val); - } - } - } - - return res; - }; - } - - if (!Array.prototype.map) { - /** - * Array.prototype.map polyfill - * - * @param {Function} callback - * @return {Array} - * - * @see https://goo.gl/SMWaMK - */ - Array.prototype.map = function (callback, thisArg) { - var T, A, k; - if (this === null) { - throw new TypeError(' this is null or not defined'); - } - - var O = Object(this); - var len = O.length >>> 0; - if (typeof callback !== 'function') { - throw new TypeError(callback + ' is not a function'); - } - - if (arguments.length > 1) { - T = thisArg; - } - - A = new Array(len); - k = 0; - - while (k < len) { - var kValue, mappedValue; - if (k in O) { - kValue = O[k]; - mappedValue = callback.call(T, kValue, k, O); - A[k] = mappedValue; - } - k++; - } - return A; - }; - } - - var isSupportAmd = typeof define === 'function' && define.amd; - - /** - * returns whether font is installed or not. - * - * @param {String} fontName - * @return {Boolean} - */ - var isFontInstalled = function (fontName) { - var testFontName = fontName === 'Comic Sans MS' ? 'Courier New' : 'Comic Sans MS'; - var $tester = $('<div>').css({ - position: 'absolute', - left: '-9999px', - top: '-9999px', - fontSize: '200px' - }).text('mmmmmmmmmwwwwwww').appendTo(document.body); - - var originalWidth = $tester.css('fontFamily', testFontName).width(); - var width = $tester.css('fontFamily', fontName + ',' + testFontName).width(); - - $tester.remove(); - - return originalWidth !== width; - }; - - var userAgent = navigator.userAgent; - var isMSIE = /MSIE|Trident/i.test(userAgent); - var browserVersion; - if (isMSIE) { - var matches = /MSIE (\d+[.]\d+)/.exec(userAgent); - if (matches) { - browserVersion = parseFloat(matches[1]); - } - matches = /Trident\/.*rv:([0-9]{1,}[\.0-9]{0,})/.exec(userAgent); - if (matches) { - browserVersion = parseFloat(matches[1]); - } - } - - /** - * @class core.agent - * - * Object which check platform and agent - * - * @singleton - * @alternateClassName agent - */ - var agent = { - /** @property {Boolean} [isMac=false] true if this agent is Mac */ - isMac: navigator.appVersion.indexOf('Mac') > -1, - /** @property {Boolean} [isMSIE=false] true if this agent is a Internet Explorer */ - isMSIE: isMSIE, - /** @property {Boolean} [isFF=false] true if this agent is a Firefox */ - isFF: /firefox/i.test(userAgent), - isWebkit: /webkit/i.test(userAgent), - /** @property {Boolean} [isSafari=false] true if this agent is a Safari */ - isSafari: /safari/i.test(userAgent), - /** @property {Float} browserVersion current browser version */ - browserVersion: browserVersion, - /** @property {String} jqueryVersion current jQuery version string */ - jqueryVersion: parseFloat($.fn.jquery), - isSupportAmd: isSupportAmd, - hasCodeMirror: isSupportAmd ? require.specified('CodeMirror') : !!window.CodeMirror, - isFontInstalled: isFontInstalled, - isW3CRangeSupport: !!document.createRange - }; - - return agent; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/core/async.js b/addons/web_editor/static/lib/summernote/src/js/core/async.js deleted file mode 100644 index 7bc23586e86..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/core/async.js +++ /dev/null @@ -1,68 +0,0 @@ -define('summernote/core/async', function () { - /** - * @class core.async - * - * Async functions which returns `Promise` - * - * @singleton - * @alternateClassName async - */ - var async = (function () { - /** - * @method readFileAsDataURL - * - * read contents of file as representing URL - * - * @param {File} file - * @return {Promise} - then: sDataUrl - */ - var readFileAsDataURL = function (file) { - return $.Deferred(function (deferred) { - $.extend(new FileReader(), { - onload: function (e) { - var sDataURL = e.target.result; - deferred.resolve(sDataURL); - }, - onerror: function () { - deferred.reject(this); - } - }).readAsDataURL(file); - }).promise(); - }; - - /** - * @method createImage - * - * create `<image>` from url string - * - * @param {String} sUrl - * @param {String} filename - * @return {Promise} - then: $image - */ - var createImage = function (sUrl, filename) { - return $.Deferred(function (deferred) { - var $img = $('<img>'); - - $img.one('load', function () { - $img.off('error abort'); - deferred.resolve($img); - }).one('error abort', function () { - $img.off('load').detach(); - deferred.reject($img); - }).css({ - display: 'none' - }).appendTo(document.body).attr({ - 'src': sUrl, - 'data-filename': filename - }); - }).promise(); - }; - - return { - readFileAsDataURL: readFileAsDataURL, - createImage: createImage - }; - })(); - - return async; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/core/dom.js b/addons/web_editor/static/lib/summernote/src/js/core/dom.js deleted file mode 100644 index 2250b50ac2a..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/core/dom.js +++ /dev/null @@ -1,1120 +0,0 @@ -define([ - 'summernote/core/func', - 'summernote/core/list', - 'summernote/core/agent' -], function (func, list, agent) { - - var NBSP_CHAR = String.fromCharCode(160); - var ZERO_WIDTH_NBSP_CHAR = '\ufeff'; - - /** - * @class core.dom - * - * Dom functions - * - * @singleton - * @alternateClassName dom - */ - var dom = (function () { - /** - * @method isEditable - * - * returns whether node is `note-editable` or not. - * - * @param {Node} node - * @return {Boolean} - */ - var isEditable = function (node) { - return node && $(node).hasClass('note-editable'); - }; - - /** - * @method isControlSizing - * - * returns whether node is `note-control-sizing` or not. - * - * @param {Node} node - * @return {Boolean} - */ - var isControlSizing = function (node) { - return node && $(node).hasClass('note-control-sizing'); - }; - - /** - * @method buildLayoutInfo - * - * build layoutInfo from $editor(.note-editor) - * - * @param {jQuery} $editor - * @return {Object} - * @return {Function} return.editor - * @return {Node} return.dropzone - * @return {Node} return.toolbar - * @return {Node} return.editable - * @return {Node} return.codable - * @return {Node} return.popover - * @return {Node} return.handle - * @return {Node} return.dialog - */ - var buildLayoutInfo = function ($editor) { - var makeFinder; - - // air mode - if ($editor.hasClass('note-air-editor')) { - // ODOO: editor on [data-note-id] attribute - // var id = list.last($editor.attr('id').split('-')); - var id = list.last($editor.attr('data-note-id').split('-')); - makeFinder = function (sIdPrefix) { - return function () { return $(sIdPrefix + id); }; - }; - - return { - editor: function () { return $editor; }, - holder : function () { return $editor.data('holder'); }, - editable: function () { return $editor; }, - popover: makeFinder('#note-popover-'), - handle: makeFinder('#note-handle-'), - dialog: makeFinder('#note-dialog-') - }; - - // frame mode - } else { - makeFinder = function (className, $base) { - $base = $base || $editor; - return function () { return $base.find(className); }; - }; - - var options = $editor.data('options'); - var $dialogHolder = (options && options.dialogsInBody) ? $(document.body) : null; - - return { - editor: function () { return $editor; }, - holder : function () { return $editor.data('holder'); }, - dropzone: makeFinder('.note-dropzone'), - toolbar: makeFinder('.note-toolbar'), - editable: makeFinder('.note-editable'), - codable: makeFinder('.note-codable'), - statusbar: makeFinder('.note-statusbar'), - popover: makeFinder('.note-popover'), - handle: makeFinder('.note-handle'), - dialog: makeFinder('.note-dialog', $dialogHolder) - }; - } - }; - - /** - * returns makeLayoutInfo from editor's descendant node. - * - * @private - * @param {Node} descendant - * @return {Object} - */ - var makeLayoutInfo = function (descendant) { - var $target = $(descendant).closest('.note-editor, .note-air-editor, .note-air-layout'); - - if (!$target.length) { - return null; - } - - var $editor; - if ($target.is('.note-editor, .note-air-editor')) { - $editor = $target; - } else { - // ODOO: editor on [data-note-id] attribute - // $editor = $('#note-editor-' + list.last($target.attr('id').split('-'))); - $editor = $('[data-note-id="' + list.last($target.attr('id')) + '"]'); - } - - return buildLayoutInfo($editor); - }; - - /** - * @method makePredByNodeName - * - * returns predicate which judge whether nodeName is same - * - * @param {String} nodeName - * @return {Function} - */ - var makePredByNodeName = function (nodeName) { - nodeName = nodeName.toUpperCase(); - return function (node) { - return node && node.nodeName.toUpperCase() === nodeName; - }; - }; - - /** - * @method isText - * - * - * - * @param {Node} node - * @return {Boolean} true if node's type is text(3) - */ - var isText = function (node) { - return node && node.nodeType === 3; - }; - - /** - * ex) br, col, embed, hr, img, input, ... - * @see http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements - */ - var isVoid = function (node) { - return node && /^BR|^IMG|^HR|^IFRAME|^BUTTON/.test(node.nodeName.toUpperCase()); - }; - - var isPara = function (node) { - if (isEditable(node)) { - return false; - } - - // Chrome(v31.0), FF(v25.0.1) use DIV for paragraph - return node && /^DIV|^P|^LI|^H[1-7]/.test(node.nodeName.toUpperCase()); - }; - - var isLi = makePredByNodeName('LI'); - - var isPurePara = function (node) { - return isPara(node) && !isLi(node); - }; - - var isTable = makePredByNodeName('TABLE'); - - var isInline = function (node) { - return !isBodyContainer(node) && - !isList(node) && - !isHr(node) && - !isPara(node) && - !isTable(node) && - !isBlockquote(node); - }; - - var isList = function (node) { - return node && /^UL|^OL/.test(node.nodeName.toUpperCase()); - }; - - var isHr = makePredByNodeName('HR'); - - var isCell = function (node) { - return node && /^TD|^TH/.test(node.nodeName.toUpperCase()); - }; - - var isBlockquote = makePredByNodeName('BLOCKQUOTE'); - - var isBodyContainer = function (node) { - return isCell(node) || isBlockquote(node) || isEditable(node); - }; - - var isAnchor = makePredByNodeName('A'); - - var isParaInline = function (node) { - return isInline(node) && !!ancestor(node, isPara); - }; - - var isBodyInline = function (node) { - return isInline(node) && !ancestor(node, isPara); - }; - - var isBody = makePredByNodeName('BODY'); - - /** - * returns whether nodeB is closest sibling of nodeA - * - * @param {Node} nodeA - * @param {Node} nodeB - * @return {Boolean} - */ - var isClosestSibling = function (nodeA, nodeB) { - return nodeA.nextSibling === nodeB || - nodeA.previousSibling === nodeB; - }; - - /** - * returns array of closest siblings with node - * - * @param {Node} node - * @param {function} [pred] - predicate function - * @return {Node[]} - */ - var withClosestSiblings = function (node, pred) { - pred = pred || func.ok; - - var siblings = []; - if (node.previousSibling && pred(node.previousSibling)) { - siblings.push(node.previousSibling); - } - siblings.push(node); - if (node.nextSibling && pred(node.nextSibling)) { - siblings.push(node.nextSibling); - } - return siblings; - }; - - /** - * blank HTML for cursor position - * - [workaround] old IE only works with - * - [workaround] IE11 and other browser works with bogus br - */ - var blankHTML = agent.isMSIE && agent.browserVersion < 11 ? ' ' : '<br>'; - - /** - * @method nodeLength - * - * returns #text's text size or element's childNodes size - * - * @param {Node} node - */ - var nodeLength = function (node) { - if (isText(node)) { - return node.nodeValue.length; - } - - return node.childNodes.length; - }; - - /** - * returns whether node is empty or not. - * - * @param {Node} node - * @return {Boolean} - */ - var isEmpty = function (node) { - var len = nodeLength(node); - - if (len === 0) { - return true; - } else if (!isText(node) && len === 1 && node.innerHTML === blankHTML) { - // ex) <p><br></p>, <span><br></span> - return true; - } else if (list.all(node.childNodes, isText) && node.innerHTML === '') { - // ex) <p></p>, <span></span> - return true; - } - - return false; - }; - - /** - * padding blankHTML if node is empty (for cursor position) - */ - var paddingBlankHTML = function (node) { - if (!isVoid(node) && !nodeLength(node)) { - node.innerHTML = blankHTML; - } - }; - - /** - * find nearest ancestor predicate hit - * - * @param {Node} node - * @param {Function} pred - predicate function - */ - var ancestor = function (node, pred) { - while (node) { - if (pred(node)) { return node; } - if (isEditable(node)) { break; } - - node = node.parentNode; - } - return null; - }; - - /** - * find nearest ancestor only single child blood line and predicate hit - * - * @param {Node} node - * @param {Function} pred - predicate function - */ - var singleChildAncestor = function (node, pred) { - node = node.parentNode; - - while (node) { - if (nodeLength(node) !== 1) { break; } - if (pred(node)) { return node; } - if (isEditable(node)) { break; } - - node = node.parentNode; - } - return null; - }; - - /** - * returns new array of ancestor nodes (until predicate hit). - * - * @param {Node} node - * @param {Function} [optional] pred - predicate function - */ - var listAncestor = function (node, pred) { - pred = pred || func.fail; - - var ancestors = []; - ancestor(node, function (el) { - if (!isEditable(el)) { - ancestors.push(el); - } - - return pred(el); - }); - return ancestors; - }; - - /** - * find farthest ancestor predicate hit - */ - var lastAncestor = function (node, pred) { - var ancestors = listAncestor(node); - return list.last(ancestors.filter(pred)); - }; - - /** - * returns common ancestor node between two nodes. - * - * @param {Node} nodeA - * @param {Node} nodeB - */ - var commonAncestor = function (nodeA, nodeB) { - var ancestors = listAncestor(nodeA); - for (var n = nodeB; n; n = n.parentNode) { - if ($.inArray(n, ancestors) > -1) { return n; } - } - return null; // difference document area - }; - - /** - * listing all previous siblings (until predicate hit). - * - * @param {Node} node - * @param {Function} [optional] pred - predicate function - */ - var listPrev = function (node, pred) { - pred = pred || func.fail; - - var nodes = []; - while (node) { - if (pred(node)) { break; } - nodes.push(node); - node = node.previousSibling; - } - return nodes; - }; - - /** - * listing next siblings (until predicate hit). - * - * @param {Node} node - * @param {Function} [pred] - predicate function - */ - var listNext = function (node, pred) { - pred = pred || func.fail; - - var nodes = []; - while (node) { - if (pred(node)) { break; } - nodes.push(node); - node = node.nextSibling; - } - return nodes; - }; - - /** - * listing descendant nodes - * - * @param {Node} node - * @param {Function} [pred] - predicate function - */ - var listDescendant = function (node, pred) { - var descendents = []; - pred = pred || func.ok; - - // start DFS(depth first search) with node - (function fnWalk(current) { - if (node !== current && pred(current)) { - descendents.push(current); - } - for (var idx = 0, len = current.childNodes.length; idx < len; idx++) { - fnWalk(current.childNodes[idx]); - } - })(node); - - return descendents; - }; - - /** - * wrap node with new tag. - * - * @param {Node} node - * @param {Node} tagName of wrapper - * @return {Node} - wrapper - */ - var wrap = function (node, wrapperName) { - var parent = node.parentNode; - var wrapper = $('<' + wrapperName + '>')[0]; - - parent.insertBefore(wrapper, node); - wrapper.appendChild(node); - - return wrapper; - }; - - /** - * insert node after preceding - * - * @param {Node} node - * @param {Node} preceding - predicate function - */ - var insertAfter = function (node, preceding) { - var next = preceding.nextSibling, parent = preceding.parentNode; - if (next) { - parent.insertBefore(node, next); - } else { - parent.appendChild(node); - } - return node; - }; - - /** - * append elements. - * - * @param {Node} node - * @param {Collection} aChild - */ - var appendChildNodes = function (node, aChild) { - $.each(aChild, function (idx, child) { - node.appendChild(child); - }); - return node; - }; - - /** - * returns whether boundaryPoint is left edge or not. - * - * @param {BoundaryPoint} point - * @return {Boolean} - */ - var isLeftEdgePoint = function (point) { - return point.offset === 0; - }; - - /** - * returns whether boundaryPoint is right edge or not. - * - * @param {BoundaryPoint} point - * @return {Boolean} - */ - var isRightEdgePoint = function (point) { - return point.offset === nodeLength(point.node); - }; - - /** - * returns whether boundaryPoint is edge or not. - * - * @param {BoundaryPoint} point - * @return {Boolean} - */ - var isEdgePoint = function (point) { - return isLeftEdgePoint(point) || isRightEdgePoint(point); - }; - - /** - * returns wheter node is left edge of ancestor or not. - * - * @param {Node} node - * @param {Node} ancestor - * @return {Boolean} - */ - var isLeftEdgeOf = function (node, ancestor) { - while (node && node !== ancestor) { - if (position(node) !== 0) { - return false; - } - node = node.parentNode; - } - - return true; - }; - - /** - * returns whether node is right edge of ancestor or not. - * - * @param {Node} node - * @param {Node} ancestor - * @return {Boolean} - */ - var isRightEdgeOf = function (node, ancestor) { - while (node && node !== ancestor) { - if (position(node) !== nodeLength(node.parentNode) - 1) { - return false; - } - node = node.parentNode; - } - - return true; - }; - - /** - * returns whether point is left edge of ancestor or not. - * @param {BoundaryPoint} point - * @param {Node} ancestor - * @return {Boolean} - */ - var isLeftEdgePointOf = function (point, ancestor) { - return isLeftEdgePoint(point) && isLeftEdgeOf(point.node, ancestor); - }; - - /** - * returns whether point is right edge of ancestor or not. - * @param {BoundaryPoint} point - * @param {Node} ancestor - * @return {Boolean} - */ - var isRightEdgePointOf = function (point, ancestor) { - return isRightEdgePoint(point) && isRightEdgeOf(point.node, ancestor); - }; - - /** - * returns offset from parent. - * - * @param {Node} node - */ - var position = function (node) { - var offset = 0; - while ((node = node.previousSibling)) { - offset += 1; - } - return offset; - }; - - var hasChildren = function (node) { - return !!(node && node.childNodes && node.childNodes.length); - }; - - /** - * returns previous boundaryPoint - * - * @param {BoundaryPoint} point - * @param {Boolean} isSkipInnerOffset - * @return {BoundaryPoint} - */ - var prevPoint = function (point, isSkipInnerOffset) { - var node, offset; - - if (point.offset === 0) { - if (isEditable(point.node)) { - return null; - } - - node = point.node.parentNode; - offset = position(point.node); - } else if (hasChildren(point.node)) { - node = point.node.childNodes[point.offset - 1]; - offset = nodeLength(node); - } else { - node = point.node; - offset = isSkipInnerOffset ? 0 : point.offset - 1; - } - - return { - node: node, - offset: offset - }; - }; - - /** - * returns next boundaryPoint - * - * @param {BoundaryPoint} point - * @param {Boolean} isSkipInnerOffset - * @return {BoundaryPoint} - */ - var nextPoint = function (point, isSkipInnerOffset) { - var node, offset; - - if (nodeLength(point.node) === point.offset) { - if (isEditable(point.node)) { - return null; - } - - node = point.node.parentNode; - offset = position(point.node) + 1; - } else if (hasChildren(point.node)) { - node = point.node.childNodes[point.offset]; - offset = 0; - } else { - node = point.node; - offset = isSkipInnerOffset ? nodeLength(point.node) : point.offset + 1; - } - - return { - node: node, - offset: offset - }; - }; - - /** - * returns whether pointA and pointB is same or not. - * - * @param {BoundaryPoint} pointA - * @param {BoundaryPoint} pointB - * @return {Boolean} - */ - var isSamePoint = function (pointA, pointB) { - return pointA.node === pointB.node && pointA.offset === pointB.offset; - }; - - /** - * returns whether point is visible (can set cursor) or not. - * - * @param {BoundaryPoint} point - * @return {Boolean} - */ - var isVisiblePoint = function (point) { - if (isText(point.node) || !hasChildren(point.node) || isEmpty(point.node)) { - return true; - } - - var leftNode = point.node.childNodes[point.offset - 1]; - var rightNode = point.node.childNodes[point.offset]; - if ((!leftNode || isVoid(leftNode)) && (!rightNode || isVoid(rightNode))) { - return true; - } - - return false; - }; - - /** - * @method prevPointUtil - * - * @param {BoundaryPoint} point - * @param {Function} pred - * @return {BoundaryPoint} - */ - var prevPointUntil = function (point, pred) { - while (point) { - if (pred(point)) { - return point; - } - - point = prevPoint(point); - } - - return null; - }; - - /** - * @method nextPointUntil - * - * @param {BoundaryPoint} point - * @param {Function} pred - * @return {BoundaryPoint} - */ - var nextPointUntil = function (point, pred) { - while (point) { - if (pred(point)) { - return point; - } - - point = nextPoint(point); - } - - return null; - }; - - /** - * returns whether point has character or not. - * - * @param {Point} point - * @return {Boolean} - */ - var isCharPoint = function (point) { - if (!isText(point.node)) { - return false; - } - - var ch = point.node.nodeValue.charAt(point.offset - 1); - return ch && (ch !== ' ' && ch !== NBSP_CHAR); - }; - - /** - * @method walkPoint - * - * @param {BoundaryPoint} startPoint - * @param {BoundaryPoint} endPoint - * @param {Function} handler - * @param {Boolean} isSkipInnerOffset - */ - var walkPoint = function (startPoint, endPoint, handler, isSkipInnerOffset) { - var point = startPoint; - - while (point) { - handler(point); - - if (isSamePoint(point, endPoint)) { - break; - } - - var isSkipOffset = isSkipInnerOffset && - startPoint.node !== point.node && - endPoint.node !== point.node; - point = nextPoint(point, isSkipOffset); - } - }; - - /** - * @method makeOffsetPath - * - * return offsetPath(array of offset) from ancestor - * - * @param {Node} ancestor - ancestor node - * @param {Node} node - */ - var makeOffsetPath = function (ancestor, node) { - var ancestors = listAncestor(node, func.eq(ancestor)); - return ancestors.map(position).reverse(); - }; - - /** - * @method fromOffsetPath - * - * return element from offsetPath(array of offset) - * - * @param {Node} ancestor - ancestor node - * @param {array} offsets - offsetPath - */ - var fromOffsetPath = function (ancestor, offsets) { - var current = ancestor; - for (var i = 0, len = offsets.length; i < len; i++) { - if (current.childNodes.length <= offsets[i]) { - current = current.childNodes[current.childNodes.length - 1]; - } else { - current = current.childNodes[offsets[i]]; - } - } - return current; - }; - - /** - * @method splitNode - * - * split element or #text - * - * @param {BoundaryPoint} point - * @param {Object} [options] - * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false - * @param {Boolean} [options.isNotSplitEdgePoint] - default: false - * @return {Node} right node of boundaryPoint - */ - var splitNode = function (point, options) { - var isSkipPaddingBlankHTML = options && options.isSkipPaddingBlankHTML; - var isNotSplitEdgePoint = options && options.isNotSplitEdgePoint; - - // edge case - if (isEdgePoint(point) && (isText(point.node) || isNotSplitEdgePoint)) { - if (isLeftEdgePoint(point)) { - return point.node; - } else if (isRightEdgePoint(point)) { - return point.node.nextSibling; - } - } - - // split #text - if (isText(point.node)) { - return point.node.splitText(point.offset); - } else { - var childNode = point.node.childNodes[point.offset]; - var clone = insertAfter(point.node.cloneNode(false), point.node); - appendChildNodes(clone, listNext(childNode)); - - if (!isSkipPaddingBlankHTML) { - paddingBlankHTML(point.node); - paddingBlankHTML(clone); - } - - return clone; - } - }; - - /** - * @method splitTree - * - * split tree by point - * - * @param {Node} root - split root - * @param {BoundaryPoint} point - * @param {Object} [options] - * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false - * @param {Boolean} [options.isNotSplitEdgePoint] - default: false - * @return {Node} right node of boundaryPoint - */ - var splitTree = function (root, point, options) { - // ex) [#text, <span>, <p>] - var ancestors = listAncestor(point.node, func.eq(root)); - - if (!ancestors.length) { - return null; - } else if (ancestors.length === 1) { - return splitNode(point, options); - } - - return ancestors.reduce(function (node, parent) { - if (node === point.node) { - node = splitNode(point, options); - } - - return splitNode({ - node: parent, - offset: node ? dom.position(node) : nodeLength(parent) - }, options); - }); - }; - - /** - * split point - * - * @param {Point} point - * @param {Boolean} isInline - * @return {Object} - */ - var splitPoint = function (point, isInline) { - // find splitRoot, container - // - inline: splitRoot is a child of paragraph - // - block: splitRoot is a child of bodyContainer - var pred = isInline ? isPara : isBodyContainer; - var ancestors = listAncestor(point.node, pred); - var topAncestor = list.last(ancestors) || point.node; - - var splitRoot, container; - if (pred(topAncestor)) { - splitRoot = ancestors[ancestors.length - 2]; - container = topAncestor; - } else { - splitRoot = topAncestor; - container = splitRoot.parentNode; - } - - // if splitRoot is exists, split with splitTree - var pivot = splitRoot && splitTree(splitRoot, point, { - isSkipPaddingBlankHTML: isInline, - isNotSplitEdgePoint: isInline - }); - - // if container is point.node, find pivot with point.offset - if (!pivot && container === point.node) { - pivot = point.node.childNodes[point.offset]; - } - - return { - rightNode: pivot, - container: container - }; - }; - - var create = function (nodeName) { - return document.createElement(nodeName); - }; - - var createText = function (text) { - return document.createTextNode(text); - }; - - /** - * @method remove - * - * remove node, (isRemoveChild: remove child or not) - * - * @param {Node} node - * @param {Boolean} isRemoveChild - */ - var remove = function (node, isRemoveChild) { - if (!node || !node.parentNode) { return; } - if (node.removeNode) { return node.removeNode(isRemoveChild); } - - var parent = node.parentNode; - if (!isRemoveChild) { - var nodes = []; - var i, len; - for (i = 0, len = node.childNodes.length; i < len; i++) { - nodes.push(node.childNodes[i]); - } - - for (i = 0, len = nodes.length; i < len; i++) { - parent.insertBefore(nodes[i], node); - } - } - - parent.removeChild(node); - }; - - /** - * @method removeWhile - * - * @param {Node} node - * @param {Function} pred - */ - var removeWhile = function (node, pred) { - while (node) { - if (isEditable(node) || !pred(node)) { - break; - } - - var parent = node.parentNode; - remove(node); - node = parent; - } - }; - - /** - * @method replace - * - * replace node with provided nodeName - * - * @param {Node} node - * @param {String} nodeName - * @return {Node} - new node - */ - var replace = function (node, nodeName) { - if (node.nodeName.toUpperCase() === nodeName.toUpperCase()) { - return node; - } - - var newNode = create(nodeName); - - if (node.style.cssText) { - newNode.style.cssText = node.style.cssText; - } - - appendChildNodes(newNode, list.from(node.childNodes)); - insertAfter(newNode, node); - remove(node); - - return newNode; - }; - - var isTextarea = makePredByNodeName('TEXTAREA'); - - /** - * @param {jQuery} $node - * @param {Boolean} [stripLinebreaks] - default: false - */ - var value = function ($node, stripLinebreaks) { - var val = isTextarea($node[0]) ? $node.val() : $node.html(); - if (stripLinebreaks) { - return val.replace(/[\n\r]/g, ''); - } - return val; - }; - - /** - * @method html - * - * get the HTML contents of node - * - * @param {jQuery} $node - * @param {Boolean} [isNewlineOnBlock] - */ - var html = function ($node, isNewlineOnBlock) { - var markup = value($node); - - if (isNewlineOnBlock) { - var regexTag = /<(\/?)(\b(?!!)[^>\s]*)(.*?)(\s*\/?>)/g; - markup = markup.replace(regexTag, function (match, endSlash, name) { - name = name.toUpperCase(); - var isEndOfInlineContainer = /^DIV|^TD|^TH|^P|^LI|^H[1-7]/.test(name) && - !!endSlash; - var isBlockNode = /^BLOCKQUOTE|^TABLE|^TBODY|^TR|^HR|^UL|^OL/.test(name); - - return match + ((isEndOfInlineContainer || isBlockNode) ? '\n' : ''); - }); - markup = $.trim(markup); - } - - return markup; - }; - - return { - /** @property {String} NBSP_CHAR */ - NBSP_CHAR: NBSP_CHAR, - /** @property {String} ZERO_WIDTH_NBSP_CHAR */ - ZERO_WIDTH_NBSP_CHAR: ZERO_WIDTH_NBSP_CHAR, - /** @property {String} blank */ - blank: blankHTML, - /** @property {String} emptyPara */ - emptyPara: '<p>' + blankHTML + '</p>', - makePredByNodeName: makePredByNodeName, - isEditable: isEditable, - isControlSizing: isControlSizing, - buildLayoutInfo: buildLayoutInfo, - makeLayoutInfo: makeLayoutInfo, - isText: isText, - isVoid: isVoid, - isPara: isPara, - isPurePara: isPurePara, - isInline: isInline, - isBlock: func.not(isInline), - isBodyInline: isBodyInline, - isBody: isBody, - isParaInline: isParaInline, - isList: isList, - isTable: isTable, - isCell: isCell, - isBlockquote: isBlockquote, - isBodyContainer: isBodyContainer, - isAnchor: isAnchor, - isDiv: makePredByNodeName('DIV'), - isLi: isLi, - isBR: makePredByNodeName('BR'), - isSpan: makePredByNodeName('SPAN'), - isB: makePredByNodeName('B'), - isU: makePredByNodeName('U'), - isS: makePredByNodeName('S'), - isI: makePredByNodeName('I'), - isImg: makePredByNodeName('IMG'), - isTextarea: isTextarea, - isEmpty: isEmpty, - isEmptyAnchor: func.and(isAnchor, isEmpty), - isClosestSibling: isClosestSibling, - withClosestSiblings: withClosestSiblings, - nodeLength: nodeLength, - isLeftEdgePoint: isLeftEdgePoint, - isRightEdgePoint: isRightEdgePoint, - isEdgePoint: isEdgePoint, - isLeftEdgeOf: isLeftEdgeOf, - isRightEdgeOf: isRightEdgeOf, - isLeftEdgePointOf: isLeftEdgePointOf, - isRightEdgePointOf: isRightEdgePointOf, - prevPoint: prevPoint, - nextPoint: nextPoint, - isSamePoint: isSamePoint, - isVisiblePoint: isVisiblePoint, - prevPointUntil: prevPointUntil, - nextPointUntil: nextPointUntil, - isCharPoint: isCharPoint, - walkPoint: walkPoint, - ancestor: ancestor, - singleChildAncestor: singleChildAncestor, - listAncestor: listAncestor, - lastAncestor: lastAncestor, - listNext: listNext, - listPrev: listPrev, - listDescendant: listDescendant, - commonAncestor: commonAncestor, - wrap: wrap, - insertAfter: insertAfter, - appendChildNodes: appendChildNodes, - position: position, - hasChildren: hasChildren, - makeOffsetPath: makeOffsetPath, - fromOffsetPath: fromOffsetPath, - splitTree: splitTree, - splitPoint: splitPoint, - create: create, - createText: createText, - remove: remove, - removeWhile: removeWhile, - replace: replace, - html: html, - value: value - }; - })(); - - return dom; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/core/func.js b/addons/web_editor/static/lib/summernote/src/js/core/func.js deleted file mode 100644 index 11bfdf546e0..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/core/func.js +++ /dev/null @@ -1,130 +0,0 @@ -define('summernote/core/func', function () { - /** - * @class core.func - * - * func utils (for high-order func's arg) - * - * @singleton - * @alternateClassName func - */ - var func = (function () { - var eq = function (itemA) { - return function (itemB) { - return itemA === itemB; - }; - }; - - var eq2 = function (itemA, itemB) { - return itemA === itemB; - }; - - var peq2 = function (propName) { - return function (itemA, itemB) { - return itemA[propName] === itemB[propName]; - }; - }; - - var ok = function () { - return true; - }; - - var fail = function () { - return false; - }; - - var not = function (f) { - return function () { - return !f.apply(f, arguments); - }; - }; - - var and = function (fA, fB) { - return function (item) { - return fA(item) && fB(item); - }; - }; - - var self = function (a) { - return a; - }; - - var idCounter = 0; - - /** - * generate a globally-unique id - * - * @param {String} [prefix] - */ - var uniqueId = function (prefix) { - var id = ++idCounter + ''; - return prefix ? prefix + id : id; - }; - - /** - * returns bnd (bounds) from rect - * - * - IE Compatability Issue: http://goo.gl/sRLOAo - * - Scroll Issue: http://goo.gl/sNjUc - * - * @param {Rect} rect - * @return {Object} bounds - * @return {Number} bounds.top - * @return {Number} bounds.left - * @return {Number} bounds.width - * @return {Number} bounds.height - */ - var rect2bnd = function (rect) { - var $document = $(document); - return { - top: rect.top + $document.scrollTop(), - left: rect.left + $document.scrollLeft(), - width: rect.right - rect.left, - height: rect.bottom - rect.top - }; - }; - - /** - * returns a copy of the object where the keys have become the values and the values the keys. - * @param {Object} obj - * @return {Object} - */ - var invertObject = function (obj) { - var inverted = {}; - for (var key in obj) { - if (obj.hasOwnProperty(key)) { - inverted[obj[key]] = key; - } - } - return inverted; - }; - - /** - * @param {String} namespace - * @param {String} [prefix] - * @return {String} - */ - var namespaceToCamel = function (namespace, prefix) { - prefix = prefix || ''; - return prefix + namespace.split('.').map(function (name) { - return name.substring(0, 1).toUpperCase() + name.substring(1); - }).join(''); - }; - - return { - eq: eq, - eq2: eq2, - peq2: peq2, - ok: ok, - fail: fail, - self: self, - not: not, - and: and, - uniqueId: uniqueId, - rect2bnd: rect2bnd, - invertObject: invertObject, - namespaceToCamel: namespaceToCamel - }; - })(); - - return func; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/core/key.js b/addons/web_editor/static/lib/summernote/src/js/core/key.js deleted file mode 100644 index a17b2756328..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/core/key.js +++ /dev/null @@ -1,96 +0,0 @@ -define([ - 'summernote/core/list', - 'summernote/core/func' -], function (list, func) { - /** - * @class core.key - * - * Object for keycodes. - * - * @singleton - * @alternateClassName key - */ - var key = (function () { - var keyMap = { - 'BACKSPACE': 8, - 'TAB': 9, - 'ENTER': 13, - 'SPACE': 32, - - // Arrow - 'LEFT': 37, - 'UP': 38, - 'RIGHT': 39, - 'DOWN': 40, - - // Number: 0-9 - 'NUM0': 48, - 'NUM1': 49, - 'NUM2': 50, - 'NUM3': 51, - 'NUM4': 52, - 'NUM5': 53, - 'NUM6': 54, - 'NUM7': 55, - 'NUM8': 56, - - // Alphabet: a-z - 'B': 66, - 'E': 69, - 'I': 73, - 'J': 74, - 'K': 75, - 'L': 76, - 'R': 82, - 'S': 83, - 'U': 85, - 'V': 86, - 'Y': 89, - 'Z': 90, - - 'SLASH': 191, - 'LEFTBRACKET': 219, - 'BACKSLASH': 220, - 'RIGHTBRACKET': 221 - }; - - return { - /** - * @method isEdit - * - * @param {Number} keyCode - * @return {Boolean} - */ - isEdit: function (keyCode) { - return list.contains([ - keyMap.BACKSPACE, - keyMap.TAB, - keyMap.ENTER, - keyMap.SPACe - ], keyCode); - }, - /** - * @method isMove - * - * @param {Number} keyCode - * @return {Boolean} - */ - isMove: function (keyCode) { - return list.contains([ - keyMap.LEFT, - keyMap.UP, - keyMap.RIGHT, - keyMap.DOWN - ], keyCode); - }, - /** - * @property {Object} nameFromCode - * @property {String} nameFromCode.8 "BACKSPACE" - */ - nameFromCode: func.invertObject(keyMap), - code: keyMap - }; - })(); - - return key; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/core/list.js b/addons/web_editor/static/lib/summernote/src/js/core/list.js deleted file mode 100644 index 2d6708838df..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/core/list.js +++ /dev/null @@ -1,191 +0,0 @@ -define(['summernote/core/func'], function (func) { - /** - * @class core.list - * - * list utils - * - * @singleton - * @alternateClassName list - */ - var list = (function () { - /** - * returns the first item of an array. - * - * @param {Array} array - */ - var head = function (array) { - return array[0]; - }; - - /** - * returns the last item of an array. - * - * @param {Array} array - */ - var last = function (array) { - return array[array.length - 1]; - }; - - /** - * returns everything but the last entry of the array. - * - * @param {Array} array - */ - var initial = function (array) { - return array.slice(0, array.length - 1); - }; - - /** - * returns the rest of the items in an array. - * - * @param {Array} array - */ - var tail = function (array) { - return array.slice(1); - }; - - /** - * returns item of array - */ - var find = function (array, pred) { - for (var idx = 0, len = array.length; idx < len; idx ++) { - var item = array[idx]; - if (pred(item)) { - return item; - } - } - }; - - /** - * returns true if all of the values in the array pass the predicate truth test. - */ - var all = function (array, pred) { - for (var idx = 0, len = array.length; idx < len; idx ++) { - if (!pred(array[idx])) { - return false; - } - } - return true; - }; - - /** - * returns index of item - */ - var indexOf = function (array, item) { - return $.inArray(item, array); - }; - - /** - * returns true if the value is present in the list. - */ - var contains = function (array, item) { - return indexOf(array, item) !== -1; - }; - - /** - * get sum from a list - * - * @param {Array} array - array - * @param {Function} fn - iterator - */ - var sum = function (array, fn) { - fn = fn || func.self; - return array.reduce(function (memo, v) { - return memo + fn(v); - }, 0); - }; - - /** - * returns a copy of the collection with array type. - * @param {Collection} collection - collection eg) node.childNodes, ... - */ - var from = function (collection) { - var result = [], idx = -1, length = collection.length; - while (++idx < length) { - result[idx] = collection[idx]; - } - return result; - }; - - /** - * cluster elements by predicate function. - * - * @param {Array} array - array - * @param {Function} fn - predicate function for cluster rule - * @param {Array[]} - */ - var clusterBy = function (array, fn) { - if (!array.length) { return []; } - var aTail = tail(array); - return aTail.reduce(function (memo, v) { - var aLast = last(memo); - if (fn(last(aLast), v)) { - aLast[aLast.length] = v; - } else { - memo[memo.length] = [v]; - } - return memo; - }, [[head(array)]]); - }; - - /** - * returns a copy of the array with all falsy values removed - * - * @param {Array} array - array - * @param {Function} fn - predicate function for cluster rule - */ - var compact = function (array) { - var aResult = []; - for (var idx = 0, len = array.length; idx < len; idx ++) { - if (array[idx]) { aResult.push(array[idx]); } - } - return aResult; - }; - - /** - * produces a duplicate-free version of the array - * - * @param {Array} array - */ - var unique = function (array) { - var results = []; - - for (var idx = 0, len = array.length; idx < len; idx ++) { - if (!contains(results, array[idx])) { - results.push(array[idx]); - } - } - - return results; - }; - - /** - * returns next item. - * @param {Array} array - */ - var next = function (array, item) { - var idx = indexOf(array, item); - if (idx === -1) { return null; } - - return array[idx + 1]; - }; - - /** - * returns prev item. - * @param {Array} array - */ - var prev = function (array, item) { - var idx = indexOf(array, item); - if (idx === -1) { return null; } - - return array[idx - 1]; - }; - - return { head: head, last: last, initial: initial, tail: tail, - prev: prev, next: next, find: find, contains: contains, - all: all, sum: sum, from: from, - clusterBy: clusterBy, compact: compact, unique: unique }; - })(); - - return list; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/core/range.js b/addons/web_editor/static/lib/summernote/src/js/core/range.js deleted file mode 100644 index 5c2dbe3b32c..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/core/range.js +++ /dev/null @@ -1,790 +0,0 @@ -define([ - 'summernote/core/agent', - 'summernote/core/func', - 'summernote/core/list', - 'summernote/core/dom' -], function (agent, func, list, dom) { - - var range = (function () { - - /** - * return boundaryPoint from TextRange, inspired by Andy Na's HuskyRange.js - * - * @param {TextRange} textRange - * @param {Boolean} isStart - * @return {BoundaryPoint} - * - * @see http://msdn.microsoft.com/en-us/library/ie/ms535872(v=vs.85).aspx - */ - var textRangeToPoint = function (textRange, isStart) { - var container = textRange.parentElement(), offset; - - var tester = document.body.createTextRange(), prevContainer; - var childNodes = list.from(container.childNodes); - for (offset = 0; offset < childNodes.length; offset++) { - if (dom.isText(childNodes[offset])) { - continue; - } - tester.moveToElementText(childNodes[offset]); - if (tester.compareEndPoints('StartToStart', textRange) >= 0) { - break; - } - prevContainer = childNodes[offset]; - } - - if (offset !== 0 && dom.isText(childNodes[offset - 1])) { - var textRangeStart = document.body.createTextRange(), curTextNode = null; - textRangeStart.moveToElementText(prevContainer || container); - textRangeStart.collapse(!prevContainer); - curTextNode = prevContainer ? prevContainer.nextSibling : container.firstChild; - - var pointTester = textRange.duplicate(); - pointTester.setEndPoint('StartToStart', textRangeStart); - var textCount = pointTester.text.replace(/[\r\n]/g, '').length; - - while (textCount > curTextNode.nodeValue.length && curTextNode.nextSibling) { - textCount -= curTextNode.nodeValue.length; - curTextNode = curTextNode.nextSibling; - } - - /* jshint ignore:start */ - var dummy = curTextNode.nodeValue; // enforce IE to re-reference curTextNode, hack - /* jshint ignore:end */ - - if (isStart && curTextNode.nextSibling && dom.isText(curTextNode.nextSibling) && - textCount === curTextNode.nodeValue.length) { - textCount -= curTextNode.nodeValue.length; - curTextNode = curTextNode.nextSibling; - } - - container = curTextNode; - offset = textCount; - } - - return { - cont: container, - offset: offset - }; - }; - - /** - * return TextRange from boundary point (inspired by google closure-library) - * @param {BoundaryPoint} point - * @return {TextRange} - */ - var pointToTextRange = function (point) { - var textRangeInfo = function (container, offset) { - var node, isCollapseToStart; - - if (dom.isText(container)) { - var prevTextNodes = dom.listPrev(container, func.not(dom.isText)); - var prevContainer = list.last(prevTextNodes).previousSibling; - node = prevContainer || container.parentNode; - offset += list.sum(list.tail(prevTextNodes), dom.nodeLength); - isCollapseToStart = !prevContainer; - } else { - node = container.childNodes[offset] || container; - if (dom.isText(node)) { - return textRangeInfo(node, 0); - } - - offset = 0; - isCollapseToStart = false; - } - - return { - node: node, - collapseToStart: isCollapseToStart, - offset: offset - }; - }; - - var textRange = document.body.createTextRange(); - var info = textRangeInfo(point.node, point.offset); - - textRange.moveToElementText(info.node); - textRange.collapse(info.collapseToStart); - textRange.moveStart('character', info.offset); - return textRange; - }; - - /** - * Wrapped Range - * - * @constructor - * @param {Node} sc - start container - * @param {Number} so - start offset - * @param {Node} ec - end container - * @param {Number} eo - end offset - */ - var WrappedRange = function (sc, so, ec, eo) { - this.sc = sc; - this.so = so; - this.ec = ec; - this.eo = eo; - - // nativeRange: get nativeRange from sc, so, ec, eo - var nativeRange = function () { - if (agent.isW3CRangeSupport) { - var w3cRange = document.createRange(); - w3cRange.setStart(sc, so); - w3cRange.setEnd(ec, eo); - - return w3cRange; - } else { - var textRange = pointToTextRange({ - node: sc, - offset: so - }); - - textRange.setEndPoint('EndToEnd', pointToTextRange({ - node: ec, - offset: eo - })); - - return textRange; - } - }; - - this.getPoints = function () { - return { - sc: sc, - so: so, - ec: ec, - eo: eo - }; - }; - - this.getStartPoint = function () { - return { - node: sc, - offset: so - }; - }; - - this.getEndPoint = function () { - return { - node: ec, - offset: eo - }; - }; - - /** - * select update visible range - */ - this.select = function () { - var nativeRng = nativeRange(); - if (agent.isW3CRangeSupport) { - var selection = document.getSelection(); - if (selection.rangeCount > 0) { - selection.removeAllRanges(); - } - selection.addRange(nativeRng); - } else { - nativeRng.select(); - } - - return this; - }; - - /** - * @return {WrappedRange} - */ - this.normalize = function () { - - /** - * @param {BoundaryPoint} point - * @param {Boolean} isLeftToRight - * @return {BoundaryPoint} - */ - var getVisiblePoint = function (point, isLeftToRight) { - if ((dom.isVisiblePoint(point) && !dom.isEdgePoint(point)) || - (dom.isVisiblePoint(point) && dom.isRightEdgePoint(point) && !isLeftToRight) || - (dom.isVisiblePoint(point) && dom.isLeftEdgePoint(point) && isLeftToRight) || - (dom.isVisiblePoint(point) && dom.isBlock(point.node) && dom.isEmpty(point.node))) { - return point; - } - - // point on block's edge - var block = dom.ancestor(point.node, dom.isBlock); - if (((dom.isLeftEdgePointOf(point, block) || dom.isVoid(dom.prevPoint(point).node)) && !isLeftToRight) || - ((dom.isRightEdgePointOf(point, block) || dom.isVoid(dom.nextPoint(point).node)) && isLeftToRight)) { - - // returns point already on visible point - if (dom.isVisiblePoint(point)) { - return point; - } - // reverse direction - isLeftToRight = !isLeftToRight; - } - - var nextPoint = isLeftToRight ? dom.nextPointUntil(dom.nextPoint(point), dom.isVisiblePoint) : - dom.prevPointUntil(dom.prevPoint(point), dom.isVisiblePoint); - return nextPoint || point; - }; - - var endPoint = getVisiblePoint(this.getEndPoint(), false); - var startPoint = this.isCollapsed() ? endPoint : getVisiblePoint(this.getStartPoint(), true); - - return new WrappedRange( - startPoint.node, - startPoint.offset, - endPoint.node, - endPoint.offset - ); - }; - - /** - * returns matched nodes on range - * - * @param {Function} [pred] - predicate function - * @param {Object} [options] - * @param {Boolean} [options.includeAncestor] - * @param {Boolean} [options.fullyContains] - * @return {Node[]} - */ - this.nodes = function (pred, options) { - pred = pred || func.ok; - - var includeAncestor = options && options.includeAncestor; - var fullyContains = options && options.fullyContains; - - // TODO compare points and sort - var startPoint = this.getStartPoint(); - var endPoint = this.getEndPoint(); - - var nodes = []; - var leftEdgeNodes = []; - - dom.walkPoint(startPoint, endPoint, function (point) { - if (dom.isEditable(point.node)) { - return; - } - - var node; - if (fullyContains) { - if (dom.isLeftEdgePoint(point)) { - leftEdgeNodes.push(point.node); - } - if (dom.isRightEdgePoint(point) && list.contains(leftEdgeNodes, point.node)) { - node = point.node; - } - } else if (includeAncestor) { - node = dom.ancestor(point.node, pred); - } else { - node = point.node; - } - - if (node && pred(node)) { - nodes.push(node); - } - }, true); - - return list.unique(nodes); - }; - - /** - * returns commonAncestor of range - * @return {Element} - commonAncestor - */ - this.commonAncestor = function () { - return dom.commonAncestor(sc, ec); - }; - - /** - * returns expanded range by pred - * - * @param {Function} pred - predicate function - * @return {WrappedRange} - */ - this.expand = function (pred) { - var startAncestor = dom.ancestor(sc, pred); - var endAncestor = dom.ancestor(ec, pred); - - if (!startAncestor && !endAncestor) { - return new WrappedRange(sc, so, ec, eo); - } - - var boundaryPoints = this.getPoints(); - - if (startAncestor) { - boundaryPoints.sc = startAncestor; - boundaryPoints.so = 0; - } - - if (endAncestor) { - boundaryPoints.ec = endAncestor; - boundaryPoints.eo = dom.nodeLength(endAncestor); - } - - return new WrappedRange( - boundaryPoints.sc, - boundaryPoints.so, - boundaryPoints.ec, - boundaryPoints.eo - ); - }; - - /** - * @param {Boolean} isCollapseToStart - * @return {WrappedRange} - */ - this.collapse = function (isCollapseToStart) { - if (isCollapseToStart) { - return new WrappedRange(sc, so, sc, so); - } else { - return new WrappedRange(ec, eo, ec, eo); - } - }; - - /** - * splitText on range - */ - this.splitText = function () { - var isSameContainer = sc === ec; - var boundaryPoints = this.getPoints(); - - if (dom.isText(ec) && !dom.isEdgePoint(this.getEndPoint())) { - ec.splitText(eo); - } - - if (dom.isText(sc) && !dom.isEdgePoint(this.getStartPoint())) { - boundaryPoints.sc = sc.splitText(so); - boundaryPoints.so = 0; - - if (isSameContainer) { - boundaryPoints.ec = boundaryPoints.sc; - boundaryPoints.eo = eo - so; - } - } - - return new WrappedRange( - boundaryPoints.sc, - boundaryPoints.so, - boundaryPoints.ec, - boundaryPoints.eo - ); - }; - - /** - * delete contents on range - * @return {WrappedRange} - */ - if(_.isUndefined(this.deleteContents)) // ODOO: ability to override by prototype - this.deleteContents = function () { - if (this.isCollapsed()) { - return this; - } - - var rng = this.splitText(); - var nodes = rng.nodes(null, { - fullyContains: true - }); - - // find new cursor point - var point = dom.prevPointUntil(rng.getStartPoint(), function (point) { - return !list.contains(nodes, point.node); - }); - - var emptyParents = []; - $.each(nodes, function (idx, node) { - // find empty parents - var parent = node.parentNode; - if (point.node !== parent && dom.nodeLength(parent) === 1) { - emptyParents.push(parent); - } - dom.remove(node, false); - }); - - // remove empty parents - $.each(emptyParents, function (idx, node) { - dom.remove(node, false); - }); - - return new WrappedRange( - point.node, - point.offset, - point.node, - point.offset - ).normalize(); - }; - - /** - * makeIsOn: return isOn(pred) function - */ - var makeIsOn = function (pred) { - return function () { - var ancestor = dom.ancestor(sc, pred); - return !!ancestor && (ancestor === dom.ancestor(ec, pred)); - }; - }; - - // isOnEditable: judge whether range is on editable or not - this.isOnEditable = makeIsOn(dom.isEditable); - // isOnList: judge whether range is on list node or not - this.isOnList = makeIsOn(dom.isList); - // isOnAnchor: judge whether range is on anchor node or not - this.isOnAnchor = makeIsOn(dom.isAnchor); - // isOnAnchor: judge whether range is on cell node or not - this.isOnCell = makeIsOn(dom.isCell); - - /** - * @param {Function} pred - * @return {Boolean} - */ - this.isLeftEdgeOf = function (pred) { - if (!dom.isLeftEdgePoint(this.getStartPoint())) { - return false; - } - - var node = dom.ancestor(this.sc, pred); - return node && dom.isLeftEdgeOf(this.sc, node); - }; - - /** - * returns whether range was collapsed or not - */ - this.isCollapsed = function () { - return sc === ec && so === eo; - }; - - /** - * wrap inline nodes which children of body with paragraph - * - * @return {WrappedRange} - */ - this.wrapBodyInlineWithPara = function () { - if (dom.isBodyContainer(sc) && dom.isEmpty(sc)) { - sc.innerHTML = dom.emptyPara; - return new WrappedRange(sc.firstChild, 0, sc.firstChild, 0); - } - - /** - * [workaround] firefox often create range on not visible point. so normalize here. - * - firefox: |<p>text</p>| - * - chrome: <p>|text|</p> - */ - var rng = this.normalize(); - if (dom.isParaInline(sc) || dom.isPara(sc)) { - return rng; - } - - // ODOO: insert a p tag when try to insert a br with insertNode method, if the editor is inside a p, li, h1... (start_modification - // if apply the editor to a P, LI... or inside a P, LI... - if (dom.isText(sc)) { - var node = sc; - while (node.parentNode !== document) { - node = node.parentNode; - if (/^(P|LI|H[1-7]|BUTTON|A|SPAN)/.test(node.nodeName.toUpperCase())) { - return this.normalize(); - } - } - } - // ODOO: end_modification) - - // find inline top ancestor - var topAncestor; - if (dom.isInline(rng.sc)) { - var ancestors = dom.listAncestor(rng.sc, func.not(dom.isInline)); - topAncestor = list.last(ancestors); - if (!dom.isInline(topAncestor)) { - topAncestor = ancestors[ancestors.length - 2] || rng.sc.childNodes[rng.so]; - } - } else { - topAncestor = rng.sc.childNodes[rng.so > 0 ? rng.so - 1 : 0]; - } - - // siblings not in paragraph - var inlineSiblings = dom.listPrev(topAncestor, dom.isParaInline).reverse(); - inlineSiblings = inlineSiblings.concat(dom.listNext(topAncestor.nextSibling, dom.isParaInline)); - - // wrap with paragraph - if (inlineSiblings.length) { - var para = dom.wrap(list.head(inlineSiblings), 'p'); - dom.appendChildNodes(para, list.tail(inlineSiblings)); - } - - return this.normalize(); - }; - - /** - * insert node at current cursor - * - * @param {Node} node - * @return {Node} - */ - this.insertNode = function (node) { - var rng = this.wrapBodyInlineWithPara().deleteContents(); - // ODOO: override to not split world for inserting inline - // original: var info = dom.splitPoint(rng.getStartPoint(), dom.isInline(node)); - var info = dom.splitPoint(rng.getStartPoint(), !dom.isBodyContainer(dom.ancestor(rng.sc, function(node) { return dom.isBodyContainer(node) || dom.isPara(node) }))); - - if (info.rightNode) { - info.rightNode.parentNode.insertBefore(node, info.rightNode); - } else { - info.container.appendChild(node); - } - - return node; - }; - - /** - * insert html at current cursor - */ - this.pasteHTML = function (markup) { - var contentsContainer = $('<div></div>').html(markup)[0]; - var childNodes = list.from(contentsContainer.childNodes); - - var rng = this.wrapBodyInlineWithPara().deleteContents(); - - return childNodes.reverse().map(function (childNode) { - return rng.insertNode(childNode); - }).reverse(); - }; - - /** - * returns text in range - * - * @return {String} - */ - this.toString = function () { - var nativeRng = nativeRange(); - return agent.isW3CRangeSupport ? nativeRng.toString() : nativeRng.text; - }; - - /** - * returns range for word before cursor - * - * @param {Boolean} [findAfter] - find after cursor, default: false - * @return {WrappedRange} - */ - this.getWordRange = function (findAfter) { - var endPoint = this.getEndPoint(); - - if (!dom.isCharPoint(endPoint)) { - return this; - } - - var startPoint = dom.prevPointUntil(endPoint, function (point) { - return !dom.isCharPoint(point); - }); - - if (findAfter) { - endPoint = dom.nextPointUntil(endPoint, function (point) { - return !dom.isCharPoint(point); - }); - } - - return new WrappedRange( - startPoint.node, - startPoint.offset, - endPoint.node, - endPoint.offset - ); - }; - - /** - * create offsetPath bookmark - * - * @param {Node} editable - */ - this.bookmark = function (editable) { - return { - s: { - path: dom.makeOffsetPath(editable, sc), - offset: so - }, - e: { - path: dom.makeOffsetPath(editable, ec), - offset: eo - } - }; - }; - - /** - * create offsetPath bookmark base on paragraph - * - * @param {Node[]} paras - */ - this.paraBookmark = function (paras) { - return { - s: { - path: list.tail(dom.makeOffsetPath(list.head(paras), sc)), - offset: so - }, - e: { - path: list.tail(dom.makeOffsetPath(list.last(paras), ec)), - offset: eo - } - }; - }; - - /** - * getClientRects - * @return {Rect[]} - */ - this.getClientRects = function () { - var nativeRng = nativeRange(); - return nativeRng.getClientRects(); - }; - }; - - /** - * @class core.range - * - * Data structure - * * BoundaryPoint: a point of dom tree - * * BoundaryPoints: two boundaryPoints corresponding to the start and the end of the Range - * - * See to http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html#Level-2-Range-Position - * - * @singleton - * @alternateClassName range - */ - return { - WrappedRange: WrappedRange, // ODOO: give access to WrappedRange - /** - * @method - * - * create Range Object From arguments or Browser Selection - * - * @param {Node} sc - start container - * @param {Number} so - start offset - * @param {Node} ec - end container - * @param {Number} eo - end offset - * @return {WrappedRange} - */ - create : function (sc, so, ec, eo) { - if (!arguments.length) { // from Browser Selection - if (agent.isW3CRangeSupport) { - var selection = document.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } else if (dom.isBody(selection.anchorNode)) { - // Firefox: returns entire body as range on initialization. We won't never need it. - return null; - } - - var nativeRng = selection.getRangeAt(0); - sc = nativeRng.startContainer; - so = nativeRng.startOffset; - ec = nativeRng.endContainer; - eo = nativeRng.endOffset; - } else { // IE8: TextRange - var textRange = document.selection.createRange(); - var textRangeEnd = textRange.duplicate(); - textRangeEnd.collapse(false); - var textRangeStart = textRange; - textRangeStart.collapse(true); - - var startPoint = textRangeToPoint(textRangeStart, true), - endPoint = textRangeToPoint(textRangeEnd, false); - - // same visible point case: range was collapsed. - if (dom.isText(startPoint.node) && dom.isLeftEdgePoint(startPoint) && - dom.isTextNode(endPoint.node) && dom.isRightEdgePoint(endPoint) && - endPoint.node.nextSibling === startPoint.node) { - startPoint = endPoint; - } - - sc = startPoint.cont; - so = startPoint.offset; - ec = endPoint.cont; - eo = endPoint.offset; - } - } else if (arguments.length === 2) { //collapsed - ec = sc; - eo = so; - } - return new WrappedRange(sc, so, ec, eo); - }, - - /** - * @method - * - * create WrappedRange from node - * - * @param {Node} node - * @return {WrappedRange} - */ - createFromNode: function (node) { - var sc = node; - var so = 0; - var ec = node; - var eo = dom.nodeLength(ec); - - // browsers can't target a picture or void node - if (dom.isVoid(sc)) { - so = dom.listPrev(sc).length - 1; - sc = sc.parentNode; - } - if (dom.isBR(ec)) { - eo = dom.listPrev(ec).length - 1; - ec = ec.parentNode; - } else if (dom.isVoid(ec)) { - eo = dom.listPrev(ec).length; - ec = ec.parentNode; - } - - return this.create(sc, so, ec, eo); - }, - - /** - * create WrappedRange from node after position - * - * @param {Node} node - * @return {WrappedRange} - */ - createFromNodeBefore: function (node) { - return this.createFromNode(node).collapse(true); - }, - - /** - * create WrappedRange from node after position - * - * @param {Node} node - * @return {WrappedRange} - */ - createFromNodeAfter: function (node) { - return this.createFromNode(node).collapse(); - }, - - /** - * @method - * - * create WrappedRange from bookmark - * - * @param {Node} editable - * @param {Object} bookmark - * @return {WrappedRange} - */ - createFromBookmark : function (editable, bookmark) { - var sc = dom.fromOffsetPath(editable, bookmark.s.path); - var so = bookmark.s.offset; - var ec = dom.fromOffsetPath(editable, bookmark.e.path); - var eo = bookmark.e.offset; - return new WrappedRange(sc, so, ec, eo); - }, - - /** - * @method - * - * create WrappedRange from paraBookmark - * - * @param {Object} bookmark - * @param {Node[]} paras - * @return {WrappedRange} - */ - createFromParaBookmark: function (bookmark, paras) { - var so = bookmark.s.offset; - var eo = bookmark.e.offset; - var sc = dom.fromOffsetPath(list.head(paras), bookmark.s.path); - var ec = dom.fromOffsetPath(list.last(paras), bookmark.e.path); - - return new WrappedRange(sc, so, ec, eo); - } - }; - })(); - - return range; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/defaults.js b/addons/web_editor/static/lib/summernote/src/js/defaults.js deleted file mode 100644 index 9d8e0bab661..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/defaults.js +++ /dev/null @@ -1,416 +0,0 @@ -define('summernote/defaults', function () { - /** - * @class defaults - * - * @singleton - */ - var defaults = { - /** @property */ - version: '@VERSION', - - /** - * - * for event options, reference to EventHandler.attach - * - * @property {Object} options - * @property {String/Number} [options.width=null] set editor width - * @property {String/Number} [options.height=null] set editor height, ex) 300 - * @property {String/Number} options.minHeight set minimum height of editor - * @property {String/Number} options.maxHeight - * @property {String/Number} options.focus - * @property {Number} options.tabsize - * @property {Boolean} options.styleWithSpan - * @property {Object} options.codemirror - * @property {Object} [options.codemirror.mode='text/html'] - * @property {Object} [options.codemirror.htmlMode=true] - * @property {Object} [options.codemirror.lineNumbers=true] - * @property {String} [options.lang=en-US] language 'en-US', 'ko-KR', ... - * @property {String} [options.direction=null] text direction, ex) 'rtl' - * @property {Array} [options.toolbar] - * @property {Boolean} [options.airMode=false] - * @property {Array} [options.airPopover] - * @property {Fucntion} [options.onInit] initialize - * @property {Fucntion} [options.onsubmit] - */ - options: { - width: null, // set editor width - height: null, // set editor height, ex) 300 - - minHeight: null, // set minimum height of editor - maxHeight: null, // set maximum height of editor - - focus: false, // set focus to editable area after initializing summernote - - tabsize: 4, // size of tab ex) 2 or 4 - styleWithSpan: true, // style with span (Chrome and FF only) - - disableLinkTarget: false, // hide link Target Checkbox - disableDragAndDrop: false, // disable drag and drop event - disableResizeEditor: false, // disable resizing editor - disableResizeImage: false, // disable resizing image - - shortcuts: true, // enable keyboard shortcuts - - textareaAutoSync: true, // enable textarea auto sync - - placeholder: false, // enable placeholder text - prettifyHtml: true, // enable prettifying html while toggling codeview - - iconPrefix: 'fa fa-', // prefix for css icon classes - - icons: { - font: { - bold: 'bold', - italic: 'italic', - underline: 'underline', - clear: 'eraser', - height: 'text-height', - strikethrough: 'strikethrough', - superscript: 'superscript', - subscript: 'subscript' - }, - image: { - image: 'picture-o', - floatLeft: 'align-left', - floatRight: 'align-right', - floatNone: 'align-justify', - shapeRounded: 'square', - shapeCircle: 'circle-o', - shapeThumbnail: 'picture-o', - shapeNone: 'times', - remove: 'trash-o' - }, - link: { - link: 'link', - unlink: 'unlink', - edit: 'edit' - }, - table: { - table: 'table' - }, - hr: { - insert: 'minus' - }, - style: { - style: 'magic' - }, - lists: { - unordered: 'list-ul', - ordered: 'list-ol' - }, - options: { - help: 'question', - fullscreen: 'arrows-alt', - codeview: 'code' - }, - paragraph: { - paragraph: 'align-left', - outdent: 'outdent', - indent: 'indent', - left: 'align-left', - center: 'align-center', - right: 'align-right', - justify: 'align-justify' - }, - color: { - recent: 'font' - }, - history: { - undo: 'undo', - redo: 'repeat' - }, - misc: { - check: 'check' - } - }, - - dialogsInBody: false, // false will add dialogs into editor - - codemirror: { // codemirror options - mode: 'text/html', - htmlMode: true, - lineNumbers: true - }, - - // language - lang: 'en-US', // language 'en-US', 'ko-KR', ... - direction: null, // text direction, ex) 'rtl' - - // toolbar - toolbar: [ - ['style', ['style']], - ['font', ['bold', 'italic', 'underline', 'clear']], - // ['font', ['bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript', 'clear']], - ['fontname', ['fontname']], - ['fontsize', ['fontsize']], - ['color', ['color']], - ['para', ['ul', 'ol', 'paragraph']], - ['height', ['height']], - ['table', ['table']], - ['insert', ['link', 'picture', 'hr']], - ['view', ['fullscreen', 'codeview']], - ['help', ['help']] - ], - - plugin : { }, - - // air mode: inline editor - airMode: false, - // airPopover: [ - // ['style', ['style']], - // ['font', ['bold', 'italic', 'underline', 'clear']], - // ['fontname', ['fontname']], - // ['color', ['color']], - // ['para', ['ul', 'ol', 'paragraph']], - // ['height', ['height']], - // ['table', ['table']], - // ['insert', ['link', 'picture']], - // ['help', ['help']] - // ], - airPopover: [ - ['color', ['color']], - ['font', ['bold', 'underline', 'clear']], - ['para', ['ul', 'paragraph']], - ['table', ['table']], - ['insert', ['link', 'picture']] - ], - - // style tag - styleTags: ['p', 'blockquote', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'], - - // default fontName - defaultFontName: 'Helvetica Neue', - - // fontName - fontNames: [ - 'Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', - 'Helvetica Neue', 'Helvetica', 'Impact', 'Lucida Grande', - 'Tahoma', 'Times New Roman', 'Verdana' - ], - fontNamesIgnoreCheck: [], - - fontSizes: ['8', '9', '10', '11', '12', '14', '18', '24', '36'], - - // pallete colors(n x n) - colors: [ - ['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'], - ['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'], - ['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'], - ['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'], - ['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'], - ['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'], - ['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'], - ['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031'] - ], - - // lineHeight - lineHeights: ['1.0', '1.2', '1.4', '1.5', '1.6', '1.8', '2.0', '3.0'], - - // insertTable max size - insertTableMaxSize: { - col: 10, - row: 10 - }, - - // image - maximumImageFileSize: null, // size in bytes, null = no limit - - // callbacks - oninit: null, // initialize - onfocus: null, // editable has focus - onblur: null, // editable out of focus - onenter: null, // enter key pressed - onkeyup: null, // keyup - onkeydown: null, // keydown - onImageUpload: null, // imageUpload - onImageUploadError: null, // imageUploadError - onMediaDelete: null, // media delete - onToolbarClick: null, - onsubmit: null, - - /** - * manipulate link address when user create link - * @param {String} sLinkUrl - * @return {String} - */ - onCreateLink: function (sLinkUrl) { - if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) { - sLinkUrl = 'mailto:' + sLinkUrl; - } - - return sLinkUrl; - }, - - keyMap: { - pc: { - 'ENTER': 'insertParagraph', - 'CTRL+Z': 'undo', - 'CTRL+Y': 'redo', - 'TAB': 'tab', - 'SHIFT+TAB': 'untab', - 'CTRL+B': 'bold', - 'CTRL+I': 'italic', - 'CTRL+U': 'underline', - 'CTRL+SHIFT+S': 'strikethrough', - 'CTRL+BACKSLASH': 'removeFormat', - 'CTRL+SHIFT+L': 'justifyLeft', - 'CTRL+SHIFT+E': 'justifyCenter', - 'CTRL+SHIFT+R': 'justifyRight', - 'CTRL+SHIFT+J': 'justifyFull', - 'CTRL+SHIFT+NUM7': 'insertUnorderedList', - 'CTRL+SHIFT+NUM8': 'insertOrderedList', - 'CTRL+LEFTBRACKET': 'outdent', - 'CTRL+RIGHTBRACKET': 'indent', - 'CTRL+NUM0': 'formatPara', - 'CTRL+NUM1': 'formatH1', - 'CTRL+NUM2': 'formatH2', - 'CTRL+NUM3': 'formatH3', - 'CTRL+NUM4': 'formatH4', - 'CTRL+NUM5': 'formatH5', - 'CTRL+NUM6': 'formatH6', - 'CTRL+ENTER': 'insertHorizontalRule', - 'CTRL+K': 'showLinkDialog' - }, - - mac: { - 'ENTER': 'insertParagraph', - 'CMD+Z': 'undo', - 'CMD+SHIFT+Z': 'redo', - 'TAB': 'tab', - 'SHIFT+TAB': 'untab', - 'CMD+B': 'bold', - 'CMD+I': 'italic', - 'CMD+U': 'underline', - 'CMD+SHIFT+S': 'strikethrough', - 'CMD+BACKSLASH': 'removeFormat', - 'CMD+SHIFT+L': 'justifyLeft', - 'CMD+SHIFT+E': 'justifyCenter', - 'CMD+SHIFT+R': 'justifyRight', - 'CMD+SHIFT+J': 'justifyFull', - 'CMD+SHIFT+NUM7': 'insertUnorderedList', - 'CMD+SHIFT+NUM8': 'insertOrderedList', - 'CMD+LEFTBRACKET': 'outdent', - 'CMD+RIGHTBRACKET': 'indent', - 'CMD+NUM0': 'formatPara', - 'CMD+NUM1': 'formatH1', - 'CMD+NUM2': 'formatH2', - 'CMD+NUM3': 'formatH3', - 'CMD+NUM4': 'formatH4', - 'CMD+NUM5': 'formatH5', - 'CMD+NUM6': 'formatH6', - 'CMD+ENTER': 'insertHorizontalRule', - 'CMD+K': 'showLinkDialog' - } - } - }, - - // default language: en-US - lang: { - 'en-US': { - font: { - bold: 'Bold', - italic: 'Italic', - underline: 'Underline', - clear: 'Remove Font Style', - height: 'Line Height', - name: 'Font Family', - strikethrough: 'Strikethrough', - subscript: 'Subscript', - superscript: 'Superscript', - size: 'Font Size' - }, - image: { - image: 'Picture', - insert: 'Insert Image', - resizeFull: 'Resize Full', - resizeHalf: 'Resize Half', - resizeQuarter: 'Resize Quarter', - floatLeft: 'Float Left', - floatRight: 'Float Right', - floatNone: 'Float None', - shapeRounded: 'Shape: Rounded', - shapeCircle: 'Shape: Circle', - shapeThumbnail: 'Shape: Thumbnail', - shapeNone: 'Shape: None', - dragImageHere: 'Drag image or text here', - dropImage: 'Drop image or Text', - selectFromFiles: 'Select from files', - maximumFileSize: 'Maximum file size', - maximumFileSizeError: 'Maximum file size exceeded.', - url: 'Image URL', - remove: 'Remove Image' - }, - link: { - link: 'Link', - insert: 'Insert Link', - unlink: 'Unlink', - edit: 'Edit', - textToDisplay: 'Text to display', - url: 'To what URL should this link go?', - openInNewWindow: 'Open in new window' - }, - table: { - table: 'Table' - }, - hr: { - insert: 'Insert Horizontal Rule' - }, - style: { - style: 'Style', - normal: 'Paragraph', - blockquote: 'Quote', - pre: 'Code', - h1: 'Header 1', - h2: 'Header 2', - h3: 'Header 3', - h4: 'Header 4', - h5: 'Header 5', - h6: 'Header 6' - }, - lists: { - unordered: 'Unordered list', - ordered: 'Ordered list' - }, - options: { - help: 'Help', - fullscreen: 'Full Screen', - codeview: 'Code View' - }, - paragraph: { - paragraph: 'Paragraph', - outdent: 'Outdent', - indent: 'Indent', - left: 'Align left', - center: 'Align center', - right: 'Align right', - justify: 'Justify full' - }, - color: { - recent: 'Recent Color', - more: 'More Color', - background: 'Background Color', - foreground: 'Foreground Color', - transparent: 'Transparent', - setTransparent: 'Set transparent', - reset: 'Reset', - resetToDefault: 'Reset to default' - }, - shortcut: { - shortcuts: 'Keyboard shortcuts', - close: 'Close', - textFormatting: 'Text formatting', - action: 'Action', - paragraphFormatting: 'Paragraph formatting', - documentStyle: 'Document Style', - extraKeys: 'Extra keys' - }, - history: { - undo: 'Undo', - redo: 'Redo' - } - } - } - }; - - return defaults; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Bullet.js b/addons/web_editor/static/lib/summernote/src/js/editing/Bullet.js deleted file mode 100644 index 6a115edaa90..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/editing/Bullet.js +++ /dev/null @@ -1,235 +0,0 @@ -define([ - 'summernote/core/list', - 'summernote/core/func', - 'summernote/core/dom', - 'summernote/core/range' -], function (list, func, dom, range) { - - /** - * @class editing.Bullet - * - * @alternateClassName Bullet - */ - var Bullet = function () { - /** - * @method insertOrderedList - * - * toggle ordered list - * - * @type command - */ - this.insertOrderedList = function () { - this.toggleList('OL'); - }; - - /** - * @method insertUnorderedList - * - * toggle unordered list - * - * @type command - */ - this.insertUnorderedList = function () { - this.toggleList('UL'); - }; - - /** - * @method indent - * - * indent - * - * @type command - */ - this.indent = function () { - var self = this; - var rng = range.create().wrapBodyInlineWithPara(); - - var paras = rng.nodes(dom.isPara, { includeAncestor: true }); - var clustereds = list.clusterBy(paras, func.peq2('parentNode')); - - $.each(clustereds, function (idx, paras) { - var head = list.head(paras); - if (dom.isLi(head)) { - self.wrapList(paras, head.parentNode.nodeName); - } else { - $.each(paras, function (idx, para) { - $(para).css('marginLeft', function (idx, val) { - return (parseInt(val, 10) || 0) + 25; - }); - }); - } - }); - - rng.select(); - }; - - /** - * @method outdent - * - * outdent - * - * @type command - */ - this.outdent = function () { - var self = this; - var rng = range.create().wrapBodyInlineWithPara(); - - var paras = rng.nodes(dom.isPara, { includeAncestor: true }); - var clustereds = list.clusterBy(paras, func.peq2('parentNode')); - - $.each(clustereds, function (idx, paras) { - var head = list.head(paras); - if (dom.isLi(head)) { - self.releaseList([paras]); - } else { - $.each(paras, function (idx, para) { - $(para).css('marginLeft', function (idx, val) { - val = (parseInt(val, 10) || 0); - return val > 25 ? val - 25 : ''; - }); - }); - } - }); - - rng.select(); - }; - - /** - * @method toggleList - * - * toggle list - * - * @param {String} listName - OL or UL - */ - this.toggleList = function (listName) { - var self = this; - var rng = range.create().wrapBodyInlineWithPara(); - - var paras = rng.nodes(dom.isPara, { includeAncestor: true }); - var bookmark = rng.paraBookmark(paras); - var clustereds = list.clusterBy(paras, func.peq2('parentNode')); - - // paragraph to list - if (list.find(paras, dom.isPurePara)) { - var wrappedParas = []; - $.each(clustereds, function (idx, paras) { - wrappedParas = wrappedParas.concat(self.wrapList(paras, listName)); - }); - paras = wrappedParas; - // list to paragraph or change list style - } else { - var diffLists = rng.nodes(dom.isList, { - includeAncestor: true - }).filter(function (listNode) { - return !$.nodeName(listNode, listName); - }); - - if (diffLists.length) { - $.each(diffLists, function (idx, listNode) { - dom.replace(listNode, listName); - }); - } else { - paras = this.releaseList(clustereds, true); - } - } - - range.createFromParaBookmark(bookmark, paras).select(); - }; - - /** - * @method wrapList - * - * @param {Node[]} paras - * @param {String} listName - * @return {Node[]} - */ - this.wrapList = function (paras, listName) { - var head = list.head(paras); - var last = list.last(paras); - - var prevList = dom.isList(head.previousSibling) && head.previousSibling; - var nextList = dom.isList(last.nextSibling) && last.nextSibling; - - var listNode = prevList || dom.insertAfter(dom.create(listName || 'UL'), last); - - // P to LI - paras = paras.map(function (para) { - return dom.isPurePara(para) ? dom.replace(para, 'LI') : para; - }); - - // append to list(<ul>, <ol>) - dom.appendChildNodes(listNode, paras); - - if (nextList) { - dom.appendChildNodes(listNode, list.from(nextList.childNodes)); - dom.remove(nextList); - } - - return paras; - }; - - /** - * @method releaseList - * - * @param {Array[]} clustereds - * @param {Boolean} isEscapseToBody - * @return {Node[]} - */ - this.releaseList = function (clustereds, isEscapseToBody) { - var releasedParas = []; - - $.each(clustereds, function (idx, paras) { - var head = list.head(paras); - var last = list.last(paras); - - var headList = isEscapseToBody ? dom.lastAncestor(head, dom.isList) : - head.parentNode; - var lastList = headList.childNodes.length > 1 ? dom.splitTree(headList, { - node: last.parentNode, - offset: dom.position(last) + 1 - }, { - isSkipPaddingBlankHTML: true - }) : null; - - var middleList = dom.splitTree(headList, { - node: head.parentNode, - offset: dom.position(head) - }, { - isSkipPaddingBlankHTML: true - }); - - paras = isEscapseToBody ? dom.listDescendant(middleList, dom.isLi) : - list.from(middleList.childNodes).filter(dom.isLi); - - // LI to P - if (isEscapseToBody || !dom.isList(headList.parentNode)) { - paras = paras.map(function (para) { - return dom.replace(para, 'P'); - }); - } - - $.each(list.from(paras).reverse(), function (idx, para) { - dom.insertAfter(para, headList); - }); - - // remove empty lists - var rootLists = list.compact([headList, middleList, lastList]); - $.each(rootLists, function (idx, rootList) { - var listNodes = [rootList].concat(dom.listDescendant(rootList, dom.isList)); - $.each(listNodes.reverse(), function (idx, listNode) { - if (!dom.nodeLength(listNode)) { - dom.remove(listNode, true); - } - }); - }); - - releasedParas = releasedParas.concat(paras); - }); - - return releasedParas; - }; - }; - - return Bullet; -}); - diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/History.js b/addons/web_editor/static/lib/summernote/src/js/editing/History.js deleted file mode 100644 index 2cc946488c2..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/editing/History.js +++ /dev/null @@ -1,118 +0,0 @@ -define(['summernote/core/range', 'summernote/core/dom'], function (range, dom) { // ODOO: suggest upstream - /** - * @class editing.History - * - * Editor History - * - */ - var History = function ($editable) { - var stack = [], stackOffset = -1; - var editable = $editable[0]; - - var makeSnapshot = function () { - var rng = range.create(); - var emptyBookmark = {s: {path: [], offset: 0}, e: {path: [], offset: 0}}; - - return { - contents: $editable.html(), - bookmark: (rng && dom.ancestor(rng.sc, dom.isEditable) ? rng.bookmark(editable) : emptyBookmark) - // ODOO: suggest upstream added " && dom.ancestor(rng.sc, dom.isEditable) " - }; - }; - - var applySnapshot = function (snapshot) { - if (snapshot.contents !== null) { - $editable.html(snapshot.contents); - } - if (snapshot.bookmark !== null) { - range.createFromBookmark(editable, snapshot.bookmark).select(); - } - }; - - /** - * undo - */ - this.undo = function () { - // Create snap shot if not yet recorded - if ($editable.html() !== stack[stackOffset].contents) { - this.recordUndo(); - } - - if (0 < stackOffset) { - stackOffset--; - applySnapshot(stack[stackOffset]); - } - }; - - /* ODOO: to suggest upstream */ - this.hasUndo = function () { - return 0 < stackOffset; - }; - - /** - * redo - */ - this.redo = function () { - if (stack.length - 1 > stackOffset) { - stackOffset++; - applySnapshot(stack[stackOffset]); - } - }; - - /* ODOO: to suggest upstream */ - this.hasRedo = function () { - return stack.length - 1 > stackOffset; - }; - - var last; // ODOO: to suggest upstream (since we may have several editor) - /** - * recorded undo - */ - this.recordUndo = function () { - // ODOO: method totally rewritten - // test event for firefox: remove stack of history because event doesn't exists - var key = typeof event !== 'undefined' ? event : false; - if (key && !event.metaKey && !event.ctrlKey && !event.altKey && event.type === "keydown") { - key = event.type + "-"; - if (event.which === 8 || event.which === 46) key += 'delete'; - else if (event.which === 13) key += 'enter'; - else key += 'other'; - if (key === last) return; - hasUndo = true; - } - last = key; - - // Wash out stack after stackOffset - if (stack.length > stackOffset+1) { - stack = stack.slice(0, stackOffset+1); - } - - if (stack[stackOffset] && stack[stackOffset].contents === $editable.html()) { - return; - } - - stackOffset++; - - // Create new snapshot and push it to the end - stack.push(makeSnapshot()); - }; - - /* ODOO: to suggest upstream */ - this.splitNext = function () { - last = false; - }; - - /* ODOO: to suggest upstream */ - this.reset = function () { - last = false; - stack = []; - stackOffset = -1; - this.recordUndo(); - }; - - // Create first undo stack - this.recordUndo(); - }; - - return History; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Style.js b/addons/web_editor/static/lib/summernote/src/js/editing/Style.js deleted file mode 100644 index bf749cd817b..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/editing/Style.js +++ /dev/null @@ -1,161 +0,0 @@ -define([ - 'summernote/core/agent', - 'summernote/core/func', - 'summernote/core/list', - 'summernote/core/dom' -], function (agent, func, list, dom) { - /** - * @class editing.Style - * - * Style - * - */ - var Style = function () { - /** - * @method jQueryCSS - * - * [workaround] for old jQuery - * passing an array of style properties to .css() - * will result in an object of property-value pairs. - * (compability with version < 1.9) - * - * @private - * @param {jQuery} $obj - * @param {Array} propertyNames - An array of one or more CSS properties. - * @return {Object} - */ - var jQueryCSS = function ($obj, propertyNames) { - if (agent.jqueryVersion < 1.9) { - var result = {}; - $.each(propertyNames, function (idx, propertyName) { - result[propertyName] = $obj.css(propertyName); - }); - return result; - } - return $obj.css.call($obj, propertyNames); - }; - - /** - * returns style object from node - * - * @param {jQuery} $node - * @return {Object} - */ - this.fromNode = function ($node) { - var properties = ['font-family', 'font-size', 'text-align', 'list-style-type', 'line-height']; - var styleInfo = jQueryCSS($node, properties) || {}; - styleInfo['font-size'] = parseInt(styleInfo['font-size'], 10); - return styleInfo; - }; - - /** - * paragraph level style - * - * @param {WrappedRange} rng - * @param {Object} styleInfo - */ - this.stylePara = function (rng, styleInfo) { - $.each(rng.nodes(dom.isPara, { - includeAncestor: true - }), function (idx, para) { - $(para).css(styleInfo); - }); - }; - - /** - * insert and returns styleNodes on range. - * - * @param {WrappedRange} rng - * @param {Object} [options] - options for styleNodes - * @param {String} [options.nodeName] - default: `SPAN` - * @param {Boolean} [options.expandClosestSibling] - default: `false` - * @param {Boolean} [options.onlyPartialContains] - default: `false` - * @return {Node[]} - */ - this.styleNodes = function (rng, options) { - rng = rng.splitText(); - - var nodeName = options && options.nodeName || 'SPAN'; - var expandClosestSibling = !!(options && options.expandClosestSibling); - var onlyPartialContains = !!(options && options.onlyPartialContains); - - if (rng.isCollapsed()) { - return [rng.insertNode(dom.create(nodeName))]; - } - - var pred = dom.makePredByNodeName(nodeName); - var nodes = rng.nodes(dom.isText, { - fullyContains: true - }).map(function (text) { - return dom.singleChildAncestor(text, pred) || dom.wrap(text, nodeName); - }); - - if (expandClosestSibling) { - if (onlyPartialContains) { - var nodesInRange = rng.nodes(); - // compose with partial contains predication - pred = func.and(pred, function (node) { - return list.contains(nodesInRange, node); - }); - } - - return nodes.map(function (node) { - var siblings = dom.withClosestSiblings(node, pred); - var head = list.head(siblings); - var tails = list.tail(siblings); - $.each(tails, function (idx, elem) { - dom.appendChildNodes(head, elem.childNodes); - dom.remove(elem); - }); - return list.head(siblings); - }); - } else { - return nodes; - } - }; - - /** - * get current style on cursor - * - * @param {WrappedRange} rng - * @return {Object} - object contains style properties. - */ - this.current = function (rng) { - var $cont = $(dom.isText(rng.sc) ? rng.sc.parentNode : rng.sc); - var styleInfo = this.fromNode($cont); - - // document.queryCommandState for toggle state - styleInfo['font-bold'] = document.queryCommandState('bold') ? 'bold' : 'normal'; - styleInfo['font-italic'] = document.queryCommandState('italic') ? 'italic' : 'normal'; - styleInfo['font-underline'] = document.queryCommandState('underline') ? 'underline' : 'normal'; - styleInfo['font-strikethrough'] = document.queryCommandState('strikeThrough') ? 'strikethrough' : 'normal'; - styleInfo['font-superscript'] = document.queryCommandState('superscript') ? 'superscript' : 'normal'; - styleInfo['font-subscript'] = document.queryCommandState('subscript') ? 'subscript' : 'normal'; - - // list-style-type to list-style(unordered, ordered) - if (!rng.isOnList()) { - styleInfo['list-style'] = 'none'; - } else { - var aOrderedType = ['circle', 'disc', 'disc-leading-zero', 'square']; - var isUnordered = $.inArray(styleInfo['list-style-type'], aOrderedType) > -1; - styleInfo['list-style'] = isUnordered ? 'unordered' : 'ordered'; - } - - var para = dom.ancestor(rng.sc, dom.isPara); - if (para && para.style['line-height']) { - styleInfo['line-height'] = para.style.lineHeight; - } else { - var lineHeight = parseInt(styleInfo['line-height'], 10) / parseInt(styleInfo['font-size'], 10); - styleInfo['line-height'] = lineHeight.toFixed(1); - } - - styleInfo.anchor = rng.isOnAnchor() && dom.ancestor(rng.sc, dom.isAnchor); - styleInfo.ancestors = dom.listAncestor(rng.sc, dom.isEditable); - styleInfo.range = rng; - - return styleInfo; - }; - }; - - return Style; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Table.js b/addons/web_editor/static/lib/summernote/src/js/editing/Table.js deleted file mode 100644 index 634d5a79d7e..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/editing/Table.js +++ /dev/null @@ -1,52 +0,0 @@ -define([ - 'summernote/core/dom', 'summernote/core/range', 'summernote/core/list' -], function (dom, range, list) { - /** - * @class editing.Table - * - * Table - * - */ - var Table = function () { - /** - * handle tab key - * - * @param {WrappedRange} rng - * @param {Boolean} isShift - */ - this.tab = function (rng, isShift) { - var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); - var table = dom.ancestor(cell, dom.isTable); - var cells = dom.listDescendant(table, dom.isCell); - - var nextCell = list[isShift ? 'prev' : 'next'](cells, cell); - if (nextCell) { - range.create(nextCell, 0).select(); - } - }; - - /** - * create empty table element - * - * @param {Number} rowCount - * @param {Number} colCount - * @return {Node} - */ - this.createTable = function (colCount, rowCount) { - var tds = [], tdHTML; - for (var idxCol = 0; idxCol < colCount; idxCol++) { - tds.push('<td>' + dom.blank + '</td>'); - } - tdHTML = tds.join(''); - - var trs = [], trHTML; - for (var idxRow = 0; idxRow < rowCount; idxRow++) { - trs.push('<tr>' + tdHTML + '</tr>'); - } - trHTML = trs.join(''); - return $('<table class="table table-bordered">' + trHTML + '</table>')[0]; - }; - }; - return Table; -}); - diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Typing.js b/addons/web_editor/static/lib/summernote/src/js/editing/Typing.js deleted file mode 100644 index f95eba9e9f3..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/editing/Typing.js +++ /dev/null @@ -1,86 +0,0 @@ -define([ - 'summernote/core/dom', - 'summernote/core/range', - 'summernote/editing/Bullet' -], function (dom, range, Bullet) { - - /** - * @class editing.Typing - * - * Typing - * - */ - var Typing = function () { - - // a Bullet instance to toggle lists off - var bullet = new Bullet(); - - /** - * insert tab - * - * @param {jQuery} $editable - * @param {WrappedRange} rng - * @param {Number} tabsize - */ - this.insertTab = function ($editable, rng, tabsize) { - var tab = dom.createText(new Array(tabsize + 1).join(dom.NBSP_CHAR)); - rng = rng.deleteContents(); - rng.insertNode(tab, true); - - rng = range.create(tab, tabsize); - rng.select(); - }; - - /** - * insert paragraph - */ - this.insertParagraph = function () { - var rng = range.create(); - - // deleteContents on range. - rng = rng.deleteContents(); - - // Wrap range if it needs to be wrapped by paragraph - rng = rng.wrapBodyInlineWithPara(); - - // finding paragraph - var splitRoot = dom.ancestor(rng.sc, dom.isPara); - - var nextPara; - // on paragraph: split paragraph - if (splitRoot) { - // if it is an empty line with li - if (dom.isEmpty(splitRoot) && dom.isLi(splitRoot)) { - // disable UL/OL and escape! - bullet.toggleList(splitRoot.parentNode.nodeName); - return; - // if new line has content (not a line break) - } else { - nextPara = dom.splitTree(splitRoot, rng.getStartPoint()); - - var emptyAnchors = dom.listDescendant(splitRoot, dom.isEmptyAnchor); - emptyAnchors = emptyAnchors.concat(dom.listDescendant(nextPara, dom.isEmptyAnchor)); - - $.each(emptyAnchors, function (idx, anchor) { - dom.remove(anchor); - }); - } - // no paragraph: insert empty paragraph - } else { - var next = rng.sc.childNodes[rng.so]; - nextPara = $(dom.emptyPara)[0]; - if (next) { - rng.sc.insertBefore(nextPara, next); - } else { - rng.sc.appendChild(nextPara); - } - } - - range.create(nextPara, 0).normalize().select(); - - }; - - }; - - return Typing; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/intro.js b/addons/web_editor/static/lib/summernote/src/js/intro.js deleted file mode 100644 index 357132eb110..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/intro.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Super simple wysiwyg editor on Bootstrap v@VERSION - * http://summernote.org/ - * - * summernote.js - * Copyright 2013-2015 Alan Hong. and other contributors - * summernote may be freely distributed under the MIT license./ - * - * Date: @DATE - */ -(function (factory) { - /* global define */ - if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(['jquery'], factory); - } else { - // Browser globals: jQuery - factory(window.jQuery); - } -}(function ($) { - 'use strict'; diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Button.js b/addons/web_editor/static/lib/summernote/src/js/module/Button.js deleted file mode 100644 index c6d28494cd0..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Button.js +++ /dev/null @@ -1,169 +0,0 @@ -define([ - 'summernote/core/list', - 'summernote/core/agent' -], function (list, agent) { - /** - * @class module.Button - * - * Button - */ - var Button = function () { - /** - * update button status - * - * @param {jQuery} $container - * @param {Object} styleInfo - */ - this.update = function ($container, styleInfo) { - /** - * handle dropdown's check mark (for fontname, fontsize, lineHeight). - * @param {jQuery} $btn - * @param {Number} value - */ - var checkDropdownMenu = function ($btn, value) { - $btn.find('.dropdown-menu .dropdown-item').each(function () { - // always compare string to avoid creating another func. - var isChecked = ($(this).data('value') + '') === (value + ''); - this.className = 'dropdown-item' + (isChecked ? ' checked' : ''); - }); - }; - - /** - * update button state(active or not). - * - * @private - * @param {String} selector - * @param {Function} pred - */ - var btnState = function (selector, pred) { - var $btn = $container.find(selector); - $btn.toggleClass('active', pred()); - }; - - if (styleInfo.image) { - var $img = $(styleInfo.image); - - btnState('button[data-event="imageShape"][data-value="rounded"]', function () { - return $img.hasClass('rounded'); - }); - btnState('button[data-event="imageShape"][data-value="rounded-circle"]', function () { - return $img.hasClass('rounded-circle'); - }); - btnState('button[data-event="imageShape"][data-value="img-thumbnail"]', function () { - return $img.hasClass('img-thumbnail'); - }); - btnState('button[data-event="imageShape"]:not([data-value])', function () { - return !$img.is('.rounded, .rounded-circle, .img-thumbnail'); - }); - - var imgFloat = $img.css('float'); - btnState('button[data-event="floatMe"][data-value="left"]', function () { - return imgFloat === 'left'; - }); - btnState('button[data-event="floatMe"][data-value="right"]', function () { - return imgFloat === 'right'; - }); - btnState('button[data-event="floatMe"][data-value="none"]', function () { - return imgFloat !== 'left' && imgFloat !== 'right'; - }); - - var style = $img.attr('style'); - btnState('button[data-event="resize"][data-value="1"]', function () { - return !!/(^|\s)(max-)?width\s*:\s*100%/.test(style); - }); - btnState('button[data-event="resize"][data-value="0.5"]', function () { - return !!/(^|\s)(max-)?width\s*:\s*50%/.test(style); - }); - btnState('button[data-event="resize"][data-value="0.25"]', function () { - return !!/(^|\s)(max-)?width\s*:\s*25%/.test(style); - }); - return; - } - - // fontname - var $fontname = $container.find('.note-fontname'); - if ($fontname.length) { - var selectedFont = styleInfo['font-family']; - if (!!selectedFont) { - - var list = selectedFont.split(','); - for (var i = 0, len = list.length; i < len; i++) { - selectedFont = list[i].replace(/[\'\"]/g, '').replace(/\s+$/, '').replace(/^\s+/, ''); - if (agent.isFontInstalled(selectedFont)) { - break; - } - } - - $fontname.find('.note-current-fontname').text(selectedFont); - checkDropdownMenu($fontname, selectedFont); - - } - } - - // fontsize - var $fontsize = $container.find('.note-fontsize'); - $fontsize.find('.note-current-fontsize').text(styleInfo['font-size']); - checkDropdownMenu($fontsize, parseFloat(styleInfo['font-size'])); - - // lineheight - var $lineHeight = $container.find('.note-height'); - checkDropdownMenu($lineHeight, parseFloat(styleInfo['line-height'])); - - btnState('button[data-event="bold"]', function () { - return styleInfo['font-bold'] === 'bold'; - }); - btnState('button[data-event="italic"]', function () { - return styleInfo['font-italic'] === 'italic'; - }); - btnState('button[data-event="underline"]', function () { - return styleInfo['font-underline'] === 'underline'; - }); - btnState('button[data-event="strikethrough"]', function () { - return styleInfo['font-strikethrough'] === 'strikethrough'; - }); - btnState('button[data-event="superscript"]', function () { - return styleInfo['font-superscript'] === 'superscript'; - }); - btnState('button[data-event="subscript"]', function () { - return styleInfo['font-subscript'] === 'subscript'; - }); - btnState('button[data-event="justifyLeft"]', function () { - return styleInfo['text-align'] === 'left' || styleInfo['text-align'] === 'start'; - }); - btnState('button[data-event="justifyCenter"]', function () { - return styleInfo['text-align'] === 'center'; - }); - btnState('button[data-event="justifyRight"]', function () { - return styleInfo['text-align'] === 'right'; - }); - btnState('button[data-event="justifyFull"]', function () { - return styleInfo['text-align'] === 'justify'; - }); - btnState('button[data-event="insertUnorderedList"]', function () { - return styleInfo['list-style'] === 'unordered'; - }); - btnState('button[data-event="insertOrderedList"]', function () { - return styleInfo['list-style'] === 'ordered'; - }); - }; - - /** - * update recent color - * - * @param {Node} button - * @param {String} eventName - * @param {Mixed} value - */ - this.updateRecentColor = function (button, eventName, value) { - var $color = $(button).closest('.note-color'); - var $recentColor = $color.find('.note-recent-color'); - var colorInfo = JSON.parse($recentColor.attr('data-value')); - colorInfo[eventName] = value; - $recentColor.attr('data-value', JSON.stringify(colorInfo)); - var sKey = eventName === 'backColor' ? 'background-color' : 'color'; - $recentColor.find('i').css(sKey, value); - }; - }; - - return Button; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js deleted file mode 100644 index 4cd9088da75..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js +++ /dev/null @@ -1,41 +0,0 @@ -define([ - 'summernote/core/list', - 'summernote/core/dom', - 'summernote/core/key', - 'summernote/core/agent', - 'summernote/core/range' -], function (list, dom, key, agent, range) { - // ODOO override: use 0.8.10 version of this, adapted for the old summernote - // version odoo is using - var Clipboard = function (handler) { - /** - * paste by clipboard event - * - * @param {Event} event - */ - var pasteByEvent = function (event) { - if (["INPUT", "TEXTAREA"].indexOf(event.target.tagName) !== -1) { - // ODOO override: from old summernote version - return; - } - - var clipboardData = event.originalEvent.clipboardData; - var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target); - var $editable = layoutInfo.editable(); - - if (clipboardData && clipboardData.items && clipboardData.items.length) { - var item = list.head(clipboardData.items); - if (item.kind === 'file' && item.type.indexOf('image/') !== -1) { - handler.insertImages(layoutInfo, [item.getAsFile()]); - } - handler.invoke('editor.afterCommand', $editable); - } - }; - - this.attach = function (layoutInfo) { - layoutInfo.editable().on('paste', pasteByEvent); - }; - }; - - return Clipboard; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Codeview.js b/addons/web_editor/static/lib/summernote/src/js/module/Codeview.js deleted file mode 100644 index 46f3d96ab12..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Codeview.js +++ /dev/null @@ -1,137 +0,0 @@ -define([ - 'summernote/core/agent', - 'summernote/core/dom' -], function (agent, dom) { - - var CodeMirror; - if (agent.hasCodeMirror) { - if (agent.isSupportAmd) { - require(['CodeMirror'], function (cm) { - CodeMirror = cm; - }); - } else { - CodeMirror = window.CodeMirror; - } - } - - /** - * @class Codeview - */ - var Codeview = function (handler) { - - this.sync = function (layoutInfo) { - var isCodeview = handler.invoke('codeview.isActivated', layoutInfo); - if (isCodeview && agent.hasCodeMirror) { - layoutInfo.codable().data('cmEditor').save(); - } - }; - - /** - * @param {Object} layoutInfo - * @return {Boolean} - */ - this.isActivated = function (layoutInfo) { - var $editor = layoutInfo.editor(); - return $editor.hasClass('codeview'); - }; - - /** - * toggle codeview - * - * @param {Object} layoutInfo - */ - this.toggle = function (layoutInfo) { - if (this.isActivated(layoutInfo)) { - this.deactivate(layoutInfo); - } else { - this.activate(layoutInfo); - } - }; - - /** - * activate code view - * - * @param {Object} layoutInfo - */ - this.activate = function (layoutInfo) { - var $editor = layoutInfo.editor(), - $toolbar = layoutInfo.toolbar(), - $editable = layoutInfo.editable(), - $codable = layoutInfo.codable(), - $popover = layoutInfo.popover(), - $handle = layoutInfo.handle(); - - var options = $editor.data('options'); - - $codable.val(dom.html($editable, options.prettifyHtml)); - $codable.height($editable.height()); - - handler.invoke('toolbar.updateCodeview', $toolbar, true); - handler.invoke('popover.hide', $popover); - handler.invoke('handle.hide', $handle); - - $editor.addClass('codeview'); - - $codable.focus(); - - // activate CodeMirror as codable - if (agent.hasCodeMirror) { - var cmEditor = CodeMirror.fromTextArea($codable[0], options.codemirror); - - // CodeMirror TernServer - if (options.codemirror.tern) { - var server = new CodeMirror.TernServer(options.codemirror.tern); - cmEditor.ternServer = server; - cmEditor.on('cursorActivity', function (cm) { - server.updateArgHints(cm); - }); - } - - // CodeMirror hasn't Padding. - cmEditor.setSize(null, $editable.outerHeight()); - $codable.data('cmEditor', cmEditor); - } - }; - - /** - * deactivate code view - * - * @param {Object} layoutInfo - */ - this.deactivate = function (layoutInfo) { - var $holder = layoutInfo.holder(), - $editor = layoutInfo.editor(), - $toolbar = layoutInfo.toolbar(), - $editable = layoutInfo.editable(), - $codable = layoutInfo.codable(); - - var options = $editor.data('options'); - - // deactivate CodeMirror as codable - if (agent.hasCodeMirror) { - var cmEditor = $codable.data('cmEditor'); - $codable.val(cmEditor.getValue()); - cmEditor.toTextArea(); - } - - var value = dom.value($codable, options.prettifyHtml) || dom.emptyPara; - var isChange = $editable.html() !== value; - - $editable.html(value); - $editable.height(options.height ? $codable.height() : 'auto'); - $editor.removeClass('codeview'); - - if (isChange) { - handler.bindCustomEvent( - $holder, $editable.data('callbacks'), 'change' - )($editable.html(), $editable); - } - - $editable.focus(); - - handler.invoke('toolbar.updateCodeview', $toolbar, false); - }; - }; - - return Codeview; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/DragAndDrop.js b/addons/web_editor/static/lib/summernote/src/js/module/DragAndDrop.js deleted file mode 100644 index b8871599c6f..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/DragAndDrop.js +++ /dev/null @@ -1,98 +0,0 @@ -define([ - 'summernote/core/dom' -], function (dom) { - var DragAndDrop = function (handler) { - var $document = $(document); - - /** - * attach Drag and Drop Events - * - * @param {Object} layoutInfo - layout Informations - * @param {Object} options - */ - this.attach = function (layoutInfo, options) { - if (options.airMode || options.disableDragAndDrop) { - // prevent default drop event - $document.on('drop', function (e) { - e.preventDefault(); - }); - } else { - this.attachDragAndDropEvent(layoutInfo, options); - } - }; - - /** - * attach Drag and Drop Events - * - * @param {Object} layoutInfo - layout Informations - * @param {Object} options - */ - this.attachDragAndDropEvent = function (layoutInfo, options) { - var collection = $(), - $editor = layoutInfo.editor(), - $dropzone = layoutInfo.dropzone(), - $dropzoneMessage = $dropzone.find('.note-dropzone-message'); - - // show dropzone on dragenter when dragging a object to document - // -but only if the editor is visible, i.e. has a positive width and height - $document.on('dragenter', function (e) { - var isCodeview = handler.invoke('codeview.isActivated', layoutInfo); - var hasEditorSize = $editor.width() > 0 && $editor.height() > 0; - if (!isCodeview && !collection.length && hasEditorSize) { - $editor.addClass('dragover'); - $dropzone.width($editor.width()); - $dropzone.height($editor.height()); - $dropzoneMessage.text(options.langInfo.image.dragImageHere); - } - collection = collection.add(e.target); - }).on('dragleave', function (e) { - collection = collection.not(e.target); - if (!collection.length) { - $editor.removeClass('dragover'); - } - }).on('drop', function () { - collection = $(); - $editor.removeClass('dragover'); - }); - - // change dropzone's message on hover. - $dropzone.on('dragenter', function () { - $dropzone.addClass('hover'); - $dropzoneMessage.text(options.langInfo.image.dropImage); - }).on('dragleave', function () { - $dropzone.removeClass('hover'); - $dropzoneMessage.text(options.langInfo.image.dragImageHere); - }); - - // attach dropImage - $dropzone.on('drop', function (event) { - - var dataTransfer = event.originalEvent.dataTransfer; - var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target); - - if (dataTransfer && dataTransfer.files && dataTransfer.files.length) { - event.preventDefault(); - layoutInfo.editable().focus(); - handler.insertImages(layoutInfo, dataTransfer.files); - } else { - var insertNodefunc = function () { - layoutInfo.holder().summernote('insertNode', this); - }; - - for (var i = 0, len = dataTransfer.types.length; i < len; i++) { - var type = dataTransfer.types[i]; - var content = dataTransfer.getData(type); - - if (type.toLowerCase().indexOf('text') > -1) { - layoutInfo.holder().summernote('pasteHTML', content); - } else { - $(content).each(insertNodefunc); - } - } - } - }).on('dragover', false); // prevent default dragover event - }; - }; - - return DragAndDrop; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Editor.js b/addons/web_editor/static/lib/summernote/src/js/module/Editor.js deleted file mode 100644 index 0b63f315cf9..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Editor.js +++ /dev/null @@ -1,865 +0,0 @@ -define([ - 'summernote/core/agent', - 'summernote/core/func', - 'summernote/core/list', - 'summernote/core/dom', - 'summernote/core/range', - 'summernote/core/async', - 'summernote/editing/Style', - 'summernote/editing/Typing', - 'summernote/editing/Table', - 'summernote/editing/Bullet' -], function (agent, func, list, dom, range, async, - Style, Typing, Table, Bullet) { - - var KEY_BOGUS = 'bogus'; - - /** - * @class editing.Editor - * - * Editor - * - */ - var Editor = function (handler) { - - var self = this; - var style = new Style(); - var table = new Table(); - var typing = new Typing(); - var bullet = new Bullet(); - - this.style = style; // ODOO: allow access for override - this.table = table; // ODOO: allow access for override - this.typing = typing; // ODOO: allow access for override - this.bullet = bullet; // ODOO: allow access for override - - /** - * @method createRange - * - * create range - * - * @param {jQuery} $editable - * @return {WrappedRange} - */ - this.createRange = function ($editable) { - this.focus($editable); - return range.create(); - }; - - /** - * @method saveRange - * - * save current range - * - * @param {jQuery} $editable - * @param {Boolean} [thenCollapse=false] - */ - this.saveRange = function ($editable, thenCollapse) { - // ODOO: scroll to top when click on input in editable m (start_modification - // this.focus($editable); - var r = range.create(); - if (!r || ($editable[0] !== r.sc && !$.contains($editable[0], r.sc))) { - $editable.focus(); - } - // ODOO: end_modication) - $editable.data('range', range.create()); - if (thenCollapse) { - range.create().collapse().select(); - } - }; - - /** - * @method saveRange - * - * save current node list to $editable.data('childNodes') - * - * @param {jQuery} $editable - */ - this.saveNode = function ($editable) { - // copy child node reference - var copy = []; - for (var key = 0, len = $editable[0].childNodes.length; key < len; key++) { - copy.push($editable[0].childNodes[key]); - } - $editable.data('childNodes', copy); - }; - - /** - * @method restoreRange - * - * restore lately range - * - * @param {jQuery} $editable - */ - this.restoreRange = function ($editable) { - var rng = $editable.data('range'); - if (rng) { - rng.select(); - this.focus($editable); - } - }; - - /** - * @method restoreNode - * - * restore lately node list - * - * @param {jQuery} $editable - */ - this.restoreNode = function ($editable) { - $editable.html(''); - var child = $editable.data('childNodes'); - for (var index = 0, len = child.length; index < len; index++) { - $editable[0].appendChild(child[index]); - } - }; - - /** - * @method currentStyle - * - * current style - * - * @param {Node} target - * @return {Object|Boolean} unfocus - */ - this.currentStyle = function (target) { - var rng = range.create(); - var styleInfo = rng && rng.isOnEditable() ? style.current(rng.normalize()) : {}; - if (dom.isImg(target)) { - styleInfo.image = target; - } - return styleInfo; - }; - - /** - * style from node - * - * @param {jQuery} $node - * @return {Object} - */ - this.styleFromNode = function ($node) { - return style.fromNode($node); - }; - - var triggerOnBeforeChange = function ($editable) { - var $holder = dom.makeLayoutInfo($editable).holder(); - handler.bindCustomEvent( - $holder, $editable.data('callbacks'), 'before.command' - )($editable.html(), $editable); - }; - - var triggerOnChange = function ($editable) { - var $holder = dom.makeLayoutInfo($editable).holder(); - handler.bindCustomEvent( - $holder, $editable.data('callbacks'), 'change' - )($editable.html(), $editable); - }; - - /** - * @method undo - * undo - * @param {jQuery} $editable - */ - this.undo = function ($editable) { - triggerOnBeforeChange($editable); - $editable.data('NoteHistory').undo(); - triggerOnChange($editable); - }; - - /** - * @method redo - * redo - * @param {jQuery} $editable - */ - this.redo = function ($editable) { - triggerOnBeforeChange($editable); - $editable.data('NoteHistory').redo(); - triggerOnChange($editable); - }; - - /** - * @method beforeCommand - * before command - * @param {jQuery} $editable - */ - var beforeCommand = this.beforeCommand = function ($editable) { - triggerOnBeforeChange($editable); - // keep focus on editable before command execution - self.focus($editable); - }; - - /** - * @method afterCommand - * after command - * @param {jQuery} $editable - * @param {Boolean} isPreventTrigger - */ - var afterCommand = this.afterCommand = function ($editable, isPreventTrigger) { - $editable.data('NoteHistory').recordUndo(); - if (!isPreventTrigger) { - triggerOnChange($editable); - } - }; - - /** - * @method bold - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method italic - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method underline - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method strikethrough - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method formatBlock - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method superscript - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method subscript - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method justifyLeft - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method justifyCenter - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method justifyRight - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method justifyFull - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method formatBlock - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method removeFormat - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method backColor - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method foreColor - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method insertHorizontalRule - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /** - * @method fontName - * - * change font name - * - * @param {jQuery} $editable - * @param {Mixed} value - */ - - /* jshint ignore:start */ - // native commands(with execCommand), generate function for execCommand - var commands = ['bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript', - 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', - 'formatBlock', 'removeFormat', - 'backColor', 'foreColor', 'fontName']; - - for (var idx = 0, len = commands.length; idx < len; idx ++) { - this[commands[idx]] = (function (sCmd) { - return function ($editable, value) { - beforeCommand($editable); - - document.execCommand(sCmd, false, value); - - afterCommand($editable, true); - }; - })(commands[idx]); - } - /* jshint ignore:end */ - - /** - * @method tab - * - * handle tab key - * - * @param {jQuery} $editable - * @param {Object} options - */ - this.tab = function ($editable, options) { - var rng = this.createRange($editable); - if (rng.isCollapsed() && rng.isOnCell()) { - table.tab(rng); - } else { - beforeCommand($editable); - typing.insertTab($editable, rng, options.tabsize); - afterCommand($editable); - } - }; - - /** - * @method untab - * - * handle shift+tab key - * - */ - this.untab = function ($editable) { - var rng = this.createRange($editable); - if (rng.isCollapsed() && rng.isOnCell()) { - table.tab(rng, true); - } - }; - - /** - * @method insertParagraph - * - * insert paragraph - * - * @param {Node} $editable - */ - this.insertParagraph = function ($editable) { - beforeCommand($editable); - typing.insertParagraph($editable); - afterCommand($editable); - }; - - /** - * @method insertOrderedList - * - * @param {jQuery} $editable - */ - this.insertOrderedList = function ($editable) { - beforeCommand($editable); - bullet.insertOrderedList($editable); - afterCommand($editable); - }; - - /** - * @param {jQuery} $editable - */ - this.insertUnorderedList = function ($editable) { - beforeCommand($editable); - bullet.insertUnorderedList($editable); - afterCommand($editable); - }; - - /** - * @param {jQuery} $editable - */ - this.indent = function ($editable) { - beforeCommand($editable); - bullet.indent($editable); - afterCommand($editable); - }; - - /** - * @param {jQuery} $editable - */ - this.outdent = function ($editable) { - beforeCommand($editable); - bullet.outdent($editable); - afterCommand($editable); - }; - - /** - * insert image - * - * @param {jQuery} $editable - * @param {String} sUrl - */ - this.insertImage = function ($editable, sUrl, filename) { - async.createImage(sUrl, filename).then(function ($image) { - beforeCommand($editable); - $image.css({ - display: '', - width: Math.min($editable.width(), $image.width()) - }); - range.create().insertNode($image[0]); - range.createFromNodeAfter($image[0]).select(); - afterCommand($editable); - }).fail(function () { - var $holder = dom.makeLayoutInfo($editable).holder(); - handler.bindCustomEvent( - $holder, $editable.data('callbacks'), 'image.upload.error' - )(); - }); - }; - - /** - * @method insertNode - * insert node - * @param {Node} $editable - * @param {Node} node - */ - this.insertNode = function ($editable, node) { - beforeCommand($editable); - range.create().insertNode(node); - range.createFromNodeAfter(node).select(); - afterCommand($editable); - }; - - /** - * insert text - * @param {Node} $editable - * @param {String} text - */ - this.insertText = function ($editable, text) { - beforeCommand($editable); - var textNode = range.create().insertNode(dom.createText(text)); - range.create(textNode, dom.nodeLength(textNode)).select(); - afterCommand($editable); - }; - - /** - * paste HTML - * @param {Node} $editable - * @param {String} markup - */ - this.pasteHTML = function ($editable, markup) { - beforeCommand($editable); - var contents = range.create().pasteHTML(markup); - range.createFromNodeAfter(list.last(contents)).select(); - afterCommand($editable); - }; - - /** - * formatBlock - * - * @param {jQuery} $editable - * @param {String} tagName - */ - this.formatBlock = function ($editable, tagName) { - beforeCommand($editable); - // [workaround] for MSIE, IE need `<` - tagName = agent.isMSIE ? '<' + tagName + '>' : tagName; - document.execCommand('FormatBlock', false, tagName); - afterCommand($editable); - }; - - this.formatPara = function ($editable) { - beforeCommand($editable); - this.formatBlock($editable, 'P'); - afterCommand($editable); - }; - - /* jshint ignore:start */ - for (var idx = 1; idx <= 6; idx ++) { - this['formatH' + idx] = function (idx) { - return function ($editable) { - this.formatBlock($editable, 'H' + idx); - }; - }(idx); - }; - /* jshint ignore:end */ - - /** - * fontSize - * - * @param {jQuery} $editable - * @param {String} value - px - */ - this.fontSize = function ($editable, value) { - var rng = range.create(); - - if (rng.isCollapsed()) { - var spans = style.styleNodes(rng); - var firstSpan = list.head(spans); - - $(spans).css({ - 'font-size': value + 'px' - }); - - // [workaround] added styled bogus span for style - // - also bogus character needed for cursor position - if (firstSpan && !dom.nodeLength(firstSpan)) { - firstSpan.innerHTML = dom.ZERO_WIDTH_NBSP_CHAR; - range.createFromNodeAfter(firstSpan.firstChild).select(); - $editable.data(KEY_BOGUS, firstSpan); - } - } else { - beforeCommand($editable); - $(style.styleNodes(rng)).css({ - 'font-size': value + 'px' - }); - afterCommand($editable); - } - }; - - /** - * insert horizontal rule - * @param {jQuery} $editable - */ - this.insertHorizontalRule = function ($editable) { - beforeCommand($editable); - - var rng = range.create(); - var hrNode = rng.insertNode($('<HR/>')[0]); - if (hrNode.nextSibling) { - range.create(hrNode.nextSibling, 0).normalize().select(); - } - - afterCommand($editable); - }; - - /** - * remove bogus node and character - */ - this.removeBogus = function ($editable) { - var bogusNode = $editable.data(KEY_BOGUS); - if (!bogusNode) { - return; - } - - var textNode = list.find(list.from(bogusNode.childNodes), dom.isText); - - var bogusCharIdx = textNode.nodeValue.indexOf(dom.ZERO_WIDTH_NBSP_CHAR); - if (bogusCharIdx !== -1) { - textNode.deleteData(bogusCharIdx, 1); - } - - if (dom.isEmpty(bogusNode)) { - dom.remove(bogusNode); - } - - $editable.removeData(KEY_BOGUS); - }; - - /** - * lineHeight - * @param {jQuery} $editable - * @param {String} value - */ - this.lineHeight = function ($editable, value) { - beforeCommand($editable); - style.stylePara(range.create(), { - lineHeight: value - }); - afterCommand($editable); - }; - - /** - * unlink - * - * @type command - * - * @param {jQuery} $editable - */ - this.unlink = function ($editable) { - var rng = this.createRange($editable); - if (rng.isOnAnchor()) { - var anchor = dom.ancestor(rng.sc, dom.isAnchor); - rng = range.createFromNode(anchor); - rng.select(); - - beforeCommand($editable); - document.execCommand('unlink'); - afterCommand($editable); - } - }; - - /** - * create link (command) - * - * @param {jQuery} $editable - * @param {Object} linkInfo - * @param {Object} options - */ - this.createLink = function ($editable, linkInfo, options) { - var linkUrl = linkInfo.url; - var linkText = linkInfo.text; - var isNewWindow = linkInfo.isNewWindow; - var rng = linkInfo.range || this.createRange($editable); - var isTextChanged = rng.toString() !== linkText; - - options = options || dom.makeLayoutInfo($editable).editor().data('options'); - - beforeCommand($editable); - - if (options.onCreateLink) { - linkUrl = options.onCreateLink(linkUrl); - } - - var anchors = []; - // ODOO: adding this branch to modify existing anchor if it fully contains the range - var ancestor_anchor = dom.ancestor(rng.sc, dom.isAnchor); - if(ancestor_anchor && ancestor_anchor === dom.ancestor(rng.ec, dom.isAnchor)) { - anchors.push($(ancestor_anchor).html(linkText).get(0)); - } else if (isTextChanged) { - // Create a new link when text changed. - var anchor = rng.insertNode($('<A>' + linkText + '</A>')[0]); - anchors.push(anchor); - } else { - anchors = style.styleNodes(rng, { - nodeName: 'A', - expandClosestSibling: true, - onlyPartialContains: true - }); - } - - $.each(anchors, function (idx, anchor) { - $(anchor).attr('href', linkUrl); - $(anchor).attr('class', linkInfo.className || null); // ODOO: addition - $(anchor).css(linkInfo.style || {}); // ODOO: addition - if (isNewWindow) { - $(anchor).attr('target', '_blank'); - } else { - $(anchor).removeAttr('target'); - } - }); - - var startRange = range.createFromNodeBefore(list.head(anchors)); - var startPoint = startRange.getStartPoint(); - var endRange = range.createFromNodeAfter(list.last(anchors)); - var endPoint = endRange.getEndPoint(); - - range.create( - startPoint.node, - startPoint.offset, - endPoint.node, - endPoint.offset - ).select(); - - afterCommand($editable); - }; - - /** - * returns link info - * - * @return {Object} - * @return {WrappedRange} return.range - * @return {String} return.text - * @return {Boolean} [return.isNewWindow=true] - * @return {String} [return.url=""] - */ - this.getLinkInfo = function ($editable) { - this.focus($editable); - - var rng = range.create().expand(dom.isAnchor); - - // Get the first anchor on range(for edit). - var $anchor = $(list.head(rng.nodes(dom.isAnchor))); - - return { - range: rng, - text: rng.toString(), - isNewWindow: $anchor.length ? $anchor.attr('target') === '_blank' : false, - url: $anchor.length ? $anchor.attr('href') : '' - }; - }; - - /** - * setting color - * - * @param {Node} $editable - * @param {Object} sObjColor color code - * @param {String} sObjColor.foreColor foreground color - * @param {String} sObjColor.backColor background color - */ - this.color = function ($editable, sObjColor) { - var oColor = JSON.parse(sObjColor); - var foreColor = oColor.foreColor, backColor = oColor.backColor; - - beforeCommand($editable); - - if (foreColor) { document.execCommand('foreColor', false, foreColor); } - if (backColor) { document.execCommand('backColor', false, backColor); } - - afterCommand($editable); - }; - - /** - * insert Table - * - * @param {Node} $editable - * @param {String} sDim dimension of table (ex : "5x5") - */ - this.insertTable = function ($editable, sDim) { - var dimension = sDim.split('x'); - beforeCommand($editable); - - var rng = range.create().deleteContents(); - rng.insertNode(table.createTable(dimension[0], dimension[1])); - afterCommand($editable); - }; - - /** - * float me - * - * @param {jQuery} $editable - * @param {String} value - * @param {jQuery} $target - */ - this.floatMe = function ($editable, value, $target) { - beforeCommand($editable); - // bootstrap - $target.removeClass('float-left float-right'); - if (value && value !== 'none') { - $target.addClass('pull-' + value); - } - - // fallback for non-bootstrap - $target.css('float', value); - afterCommand($editable); - }; - - /** - * change image shape - * - * @param {jQuery} $editable - * @param {String} value css class - * @param {Node} $target - */ - this.imageShape = function ($editable, value, $target) { - beforeCommand($editable); - - $target.removeClass('rounded rounded-circle img-thumbnail'); - - if (value) { - $target.addClass(value); - } - - afterCommand($editable); - }; - - /** - * resize overlay element - * @param {jQuery} $editable - * @param {String} value - * @param {jQuery} $target - target element - */ - this.resize = function ($editable, value, $target) { - beforeCommand($editable); - - $target.css({ - width: value * 100 + '%', - height: '' - }); - - afterCommand($editable); - }; - - /** - * @param {Position} pos - * @param {jQuery} $target - target element - * @param {Boolean} [bKeepRatio] - keep ratio - */ - this.resizeTo = function (pos, $target, bKeepRatio) { - var imageSize; - if (bKeepRatio) { - var newRatio = pos.y / pos.x; - var ratio = $target.data('ratio'); - imageSize = { - width: ratio > newRatio ? pos.x : pos.y / ratio, - height: ratio > newRatio ? pos.x * ratio : pos.y - }; - } else { - imageSize = { - width: pos.x, - height: pos.y - }; - } - - $target.css(imageSize); - }; - - /** - * remove media object - * - * @param {jQuery} $editable - * @param {String} value - dummy argument (for keep interface) - * @param {jQuery} $target - target element - */ - this.removeMedia = function ($editable, value, $target) { - beforeCommand($editable); - $target.detach(); - - handler.bindCustomEvent( - $(), $editable.data('callbacks'), 'media.delete' - )($target, $editable); - - afterCommand($editable); - }; - - /** - * set focus - * - * @param $editable - */ - this.focus = function ($editable) { - $editable.focus(); - - // [workaround] for firefox bug http://goo.gl/lVfAaI - if (agent.isFF) { - var rng = range.create(); - if (!rng || rng.isOnEditable()) { - return; - } - - range.createFromNode($editable[0]) - .normalize() - .collapse() - .select(); - } - }; - - /** - * returns whether contents is empty or not. - * - * @param {jQuery} $editable - * @return {Boolean} - */ - this.isEmpty = function ($editable) { - return dom.isEmpty($editable[0]) || dom.emptyPara === $editable.html(); - }; - }; - - return Editor; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Fullscreen.js b/addons/web_editor/static/lib/summernote/src/js/module/Fullscreen.js deleted file mode 100644 index fd837483b3b..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Fullscreen.js +++ /dev/null @@ -1,51 +0,0 @@ -define([], function () { // ODOO: remove error from Odoo define - var Fullscreen = function (handler) { - var $window = $(window); - var $scrollbar = $('html, body'); - - /** - * toggle fullscreen - * - * @param {Object} layoutInfo - */ - this.toggle = function (layoutInfo) { - - var $editor = layoutInfo.editor(), - $toolbar = layoutInfo.toolbar(), - $editable = layoutInfo.editable(), - $codable = layoutInfo.codable(); - - var resize = function (size) { - $editable.css('height', size.h); - $codable.css('height', size.h); - if ($codable.data('cmeditor')) { - $codable.data('cmeditor').setsize(null, size.h); - } - }; - - $editor.toggleClass('fullscreen'); - var isFullscreen = $editor.hasClass('fullscreen'); - if (isFullscreen) { - $editable.data('orgheight', $editable.css('height')); - - $window.on('resize', function () { - resize({ - h: $window.height() - $toolbar.outerHeight() - }); - }).trigger('resize'); - - $scrollbar.css('overflow', 'hidden'); - } else { - $window.off('resize'); - resize({ - h: $editable.data('orgheight') - }); - $scrollbar.css('overflow', 'visible'); - } - - handler.invoke('toolbar.updateFullscreen', $toolbar, isFullscreen); - }; - }; - - return Fullscreen; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Handle.js b/addons/web_editor/static/lib/summernote/src/js/module/Handle.js deleted file mode 100644 index 3b62304d706..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Handle.js +++ /dev/null @@ -1,101 +0,0 @@ -define([ - 'summernote/core/dom' -], function (dom) { - /** - * @class module.Handle - * - * Handle - */ - var Handle = function (handler) { - var $document = $(document); - - /** - * `mousedown` event handler on $handle - * - controlSizing: resize image - * - * @param {MouseEvent} event - */ - var hHandleMousedown = function (event) { - if (dom.isControlSizing(event.target)) { - event.preventDefault(); - event.stopPropagation(); - - var layoutInfo = dom.makeLayoutInfo(event.target), - $handle = layoutInfo.handle(), - $popover = layoutInfo.popover(), - $editable = layoutInfo.editable(), - $editor = layoutInfo.editor(); - - var target = $handle.find('.note-control-selection').data('target'), - $target = $(target), posStart = $target.offset(), - scrollTop = $document.scrollTop(); - - var isAirMode = $editor.data('options').airMode; - - $document.on('mousemove', function (event) { - handler.invoke('editor.resizeTo', { - x: event.clientX - posStart.left, - y: event.clientY - (posStart.top - scrollTop) - }, $target, !event.shiftKey); - - handler.invoke('handle.update', $handle, {image: target}, isAirMode); - handler.invoke('popover.update', $popover, {image: target}, isAirMode); - }).one('mouseup', function () { - $document.off('mousemove'); - handler.invoke('editor.afterCommand', $editable); - }); - - if (!$target.data('ratio')) { // original ratio. - $target.data('ratio', $target.height() / $target.width()); - } - } - }; - - this.attach = function (layoutInfo) { - layoutInfo.handle().on('mousedown', hHandleMousedown); - }; - - /** - * update handle - * @param {jQuery} $handle - * @param {Object} styleInfo - * @param {Boolean} isAirMode - */ - this.update = function ($handle, styleInfo, isAirMode) { - var $selection = $handle.find('.note-control-selection'); - if (styleInfo.image) { - var $image = $(styleInfo.image); - var pos = isAirMode ? $image.offset() : $image.position(); - - // include margin - var imageSize = { - w: $image.outerWidth(true), - h: $image.outerHeight(true) - }; - - $selection.css({ - display: 'block', - left: pos.left, - top: pos.top, - width: imageSize.w, - height: imageSize.h - }).data('target', styleInfo.image); // save current image element. - var sizingText = imageSize.w + 'x' + imageSize.h; - $selection.find('.note-control-selection-info').text(sizingText); - } else { - $selection.hide(); - } - }; - - /** - * hide - * - * @param {jQuery} $handle - */ - this.hide = function ($handle) { - $handle.children().hide(); - }; - }; - - return Handle; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/HelpDialog.js b/addons/web_editor/static/lib/summernote/src/js/module/HelpDialog.js deleted file mode 100644 index e4ff7b775ed..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/HelpDialog.js +++ /dev/null @@ -1,35 +0,0 @@ -define([], function () { // ODOO: remove error from Odoo define - var HelpDialog = function (handler) { - /** - * show help dialog - * - * @param {jQuery} $editable - * @param {jQuery} $dialog - * @return {Promise} - */ - this.showHelpDialog = function ($editable, $dialog) { - return $.Deferred(function (deferred) { - var $helpDialog = $dialog.find('.note-help-dialog'); - - $helpDialog.one('hidden.bs.modal', function () { - deferred.resolve(); - }).modal('show'); - }).promise(); - }; - - /** - * @param {Object} layoutInfo - */ - this.show = function (layoutInfo) { - var $dialog = layoutInfo.dialog(), - $editable = layoutInfo.editable(); - - handler.invoke('editor.saveRange', $editable, true); - this.showHelpDialog($editable, $dialog).then(function () { - handler.invoke('editor.restoreRange', $editable); - }); - }; - }; - - return HelpDialog; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/ImageDialog.js b/addons/web_editor/static/lib/summernote/src/js/module/ImageDialog.js deleted file mode 100644 index 042921183cc..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/ImageDialog.js +++ /dev/null @@ -1,110 +0,0 @@ -define([ - 'summernote/core/key' -], function (key) { - var ImageDialog = function (handler) { - /** - * toggle button status - * - * @private - * @param {jQuery} $btn - * @param {Boolean} isEnable - */ - var toggleBtn = function ($btn, isEnable) { - $btn.toggleClass('disabled', !isEnable); - $btn.attr('disabled', !isEnable); - }; - - /** - * bind enter key - * - * @private - * @param {jQuery} $input - * @param {jQuery} $btn - */ - var bindEnterKey = function ($input, $btn) { - $input.on('keypress', function (event) { - if (event.keyCode === key.code.ENTER) { - $btn.trigger('click'); - } - }); - }; - - this.show = function (layoutInfo) { - var $dialog = layoutInfo.dialog(), - $editable = layoutInfo.editable(); - - handler.invoke('editor.saveRange', $editable); - this.showImageDialog($editable, $dialog).then(function (data) { - handler.invoke('editor.restoreRange', $editable); - - if (typeof data === 'string') { - // image url - handler.invoke('editor.insertImage', $editable, data); - } else { - // array of files - handler.insertImages(layoutInfo, data); - } - }).fail(function () { - handler.invoke('editor.restoreRange', $editable); - }); - }; - - /** - * show image dialog - * - * @param {jQuery} $editable - * @param {jQuery} $dialog - * @return {Promise} - */ - this.showImageDialog = function ($editable, $dialog) { - return $.Deferred(function (deferred) { - var $imageDialog = $dialog.find('.note-image-dialog'); - - var $imageInput = $dialog.find('.note-image-input'), - $imageUrl = $dialog.find('.note-image-url'), - $imageBtn = $dialog.find('.note-image-btn'); - - $imageDialog.one('shown.bs.modal', function () { - // Cloning imageInput to clear element. - $imageInput.replaceWith($imageInput.clone() - .on('change', function () { - deferred.resolve(this.files || this.value); - $imageDialog.modal('hide'); - }) - .val('') - ); - - $imageBtn.click(function (event) { - event.preventDefault(); - - deferred.resolve($imageUrl.val()); - $imageDialog.modal('hide'); - }); - - $imageUrl.on('keyup paste', function (event) { - var url; - - if (event.type === 'paste') { - url = event.originalEvent.clipboardData.getData('text'); - } else { - url = $imageUrl.val(); - } - - toggleBtn($imageBtn, url); - }).val('').trigger('focus'); - bindEnterKey($imageUrl, $imageBtn); - }).one('hidden.bs.modal', function () { - $imageInput.off('change'); - $imageUrl.off('keyup paste keypress'); - $imageBtn.off('click'); - - if (deferred.state() === 'pending') { - deferred.reject(); - } - }).modal('show'); - }); - }; - }; - - return ImageDialog; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/LinkDialog.js b/addons/web_editor/static/lib/summernote/src/js/module/LinkDialog.js deleted file mode 100644 index 045119ee68a..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/LinkDialog.js +++ /dev/null @@ -1,129 +0,0 @@ -define([ - 'summernote/core/key' -], function (key) { - var LinkDialog = function (handler) { - - /** - * toggle button status - * - * @private - * @param {jQuery} $btn - * @param {Boolean} isEnable - */ - var toggleBtn = function ($btn, isEnable) { - $btn.toggleClass('disabled', !isEnable); - $btn.attr('disabled', !isEnable); - }; - - /** - * bind enter key - * - * @private - * @param {jQuery} $input - * @param {jQuery} $btn - */ - var bindEnterKey = function ($input, $btn) { - $input.on('keypress', function (event) { - if (event.keyCode === key.code.ENTER) { - $btn.trigger('click'); - } - }); - }; - - /** - * Show link dialog and set event handlers on dialog controls. - * - * @param {jQuery} $editable - * @param {jQuery} $dialog - * @param {Object} linkInfo - * @return {Promise} - */ - this.showLinkDialog = function ($editable, $dialog, linkInfo) { - return $.Deferred(function (deferred) { - var $linkDialog = $dialog.find('.note-link-dialog'); - - var $linkText = $linkDialog.find('.note-link-text'), - $linkUrl = $linkDialog.find('.note-link-url'), - $linkBtn = $linkDialog.find('.note-link-btn'), - $openInNewWindow = $linkDialog.find('input[type=checkbox]'); - - $linkDialog.one('shown.bs.modal', function () { - $linkText.val(linkInfo.text); - - $linkText.on('input', function () { - toggleBtn($linkBtn, $linkText.val() && $linkUrl.val()); - // if linktext was modified by keyup, - // stop cloning text from linkUrl - linkInfo.text = $linkText.val(); - }); - - // if no url was given, copy text to url - if (!linkInfo.url) { - linkInfo.url = linkInfo.text || 'http://'; - toggleBtn($linkBtn, linkInfo.text); - } - - $linkUrl.on('input', function () { - toggleBtn($linkBtn, $linkText.val() && $linkUrl.val()); - // display same link on `Text to display` input - // when create a new link - if (!linkInfo.text) { - $linkText.val($linkUrl.val()); - } - }).val(linkInfo.url).trigger('focus').trigger('select'); - - bindEnterKey($linkUrl, $linkBtn); - bindEnterKey($linkText, $linkBtn); - - $openInNewWindow.prop('checked', linkInfo.isNewWindow); - - $linkBtn.one('click', function (event) { - event.preventDefault(); - - deferred.resolve({ - range: linkInfo.range, - url: $linkUrl.val(), - text: $linkText.val(), - isNewWindow: $openInNewWindow.is(':checked') - }); - $linkDialog.modal('hide'); - }); - }).one('hidden.bs.modal', function () { - // detach events - $linkText.off('input keypress'); - $linkUrl.off('input keypress'); - $linkBtn.off('click'); - - if (deferred.state() === 'pending') { - deferred.reject(); - } - }).modal('show'); - }).promise(); - }; - - /** - * @param {Object} layoutInfo - */ - this.show = function (layoutInfo) { - var $editor = layoutInfo.editor(), - $dialog = layoutInfo.dialog(), - $editable = layoutInfo.editable(), - $popover = layoutInfo.popover(), - linkInfo = handler.invoke('editor.getLinkInfo', $editable); - - var options = $editor.data('options'); - - handler.invoke('editor.saveRange', $editable); - this.showLinkDialog($editable, $dialog, linkInfo).then(function (linkInfo) { - handler.invoke('editor.restoreRange', $editable); - handler.invoke('editor.createLink', $editable, linkInfo, options); - // hide popover after creating link - handler.invoke('popover.hide', $popover); - }).fail(function () { - handler.invoke('editor.restoreRange', $editable); - }); - }; - }; - - return LinkDialog; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Popover.js b/addons/web_editor/static/lib/summernote/src/js/module/Popover.js deleted file mode 100644 index f669569f6ba..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Popover.js +++ /dev/null @@ -1,129 +0,0 @@ -define([ - 'summernote/core/func', - 'summernote/core/list', - 'summernote/module/Button' -], function (func, list, Button) { - /** - * @class module.Popover - * - * Popover (http://getbootstrap.com/javascript/#popovers) - * - */ - var Popover = function () { - var button = new Button(); - - this.button = button; // ODOO: allow access for override - - /** - * returns position from placeholder - * - * @private - * @param {Node} placeholder - * @param {Object} options - * @param {Boolean} options.isAirMode - * @return {Position} - */ - var posFromPlaceholder = function (placeholder, options) { - var isAirMode = options && options.isAirMode; - var isLeftTop = options && options.isLeftTop; - - var $placeholder = $(placeholder); - var pos = isAirMode ? $placeholder.offset() : $placeholder.position(); - var height = isLeftTop ? 0 : $placeholder.outerHeight(true); // include margin - - // popover below placeholder. - return { - left: pos.left, - top: pos.top + height - }; - }; - - /** - * show popover - * - * @private - * @param {jQuery} popover - * @param {Position} pos - */ - var showPopover = function ($popover, pos) { - $popover.css({ - display: 'block', - left: pos.left, - top: pos.top - }); - }; - - var PX_POPOVER_ARROW_OFFSET_X = 20; - - /** - * update current state - * @param {jQuery} $popover - popover container - * @param {Object} styleInfo - style object - * @param {Boolean} isAirMode - */ - this.update = function ($popover, styleInfo, isAirMode) { - button.update($popover, styleInfo); - - var $linkPopover = $popover.find('.note-link-popover'); - if (styleInfo.anchor) { - var $anchor = $linkPopover.find('a'); - var href = $(styleInfo.anchor).attr('href'); - var target = $(styleInfo.anchor).attr('target'); - $anchor.attr('href', href).html(href); - if (!target) { - $anchor.removeAttr('target'); - } else { - $anchor.attr('target', '_blank'); - } - showPopover($linkPopover, posFromPlaceholder(styleInfo.anchor, { - isAirMode: isAirMode - })); - } else { - $linkPopover.hide(); - } - - var $imagePopover = $popover.find('.note-image-popover'); - if (styleInfo.image) { - showPopover($imagePopover, posFromPlaceholder(styleInfo.image, { - isAirMode: isAirMode, - isLeftTop: true - })); - } else { - $imagePopover.hide(); - } - - var $airPopover = $popover.find('.note-air-popover'); - if (isAirMode && styleInfo.range && !styleInfo.range.isCollapsed()) { - var rect = list.last(styleInfo.range.getClientRects()); - if (rect) { - var bnd = func.rect2bnd(rect); - showPopover($airPopover, { - left: Math.max(bnd.left + bnd.width / 2 - PX_POPOVER_ARROW_OFFSET_X, 0), - top: bnd.top + bnd.height - }); - } - } else { - $airPopover.hide(); - } - }; - - /** - * @param {Node} button - * @param {String} eventName - * @param {String} value - */ - this.updateRecentColor = function (button, eventName, value) { - button.updateRecentColor(button, eventName, value); - }; - - /** - * hide all popovers - * @param {jQuery} $popover - popover container - */ - this.hide = function ($popover) { - $popover.children().hide(); - }; - }; - - return Popover; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Statusbar.js b/addons/web_editor/static/lib/summernote/src/js/module/Statusbar.js deleted file mode 100644 index b50fce458eb..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Statusbar.js +++ /dev/null @@ -1,44 +0,0 @@ -define([ - 'summernote/core/dom' -], function (dom) { - var EDITABLE_PADDING = 24; - - var Statusbar = function () { - var $document = $(document); - - this.attach = function (layoutInfo, options) { - if (!options.disableResizeEditor) { - layoutInfo.statusbar().on('mousedown', hStatusbarMousedown); - } - }; - - /** - * `mousedown` event handler on statusbar - * - * @param {MouseEvent} event - */ - var hStatusbarMousedown = function (event) { - event.preventDefault(); - event.stopPropagation(); - - var $editable = dom.makeLayoutInfo(event.target).editable(); - var editableTop = $editable.offset().top - $document.scrollTop(); - - var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target); - var options = layoutInfo.editor().data('options'); - - $document.on('mousemove', function (event) { - var nHeight = event.clientY - (editableTop + EDITABLE_PADDING); - - nHeight = (options.minHeight > 0) ? Math.max(nHeight, options.minHeight) : nHeight; - nHeight = (options.maxHeight > 0) ? Math.min(nHeight, options.maxHeight) : nHeight; - - $editable.height(nHeight); - }).one('mouseup', function () { - $document.off('mousemove'); - }); - }; - }; - - return Statusbar; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Toolbar.js b/addons/web_editor/static/lib/summernote/src/js/module/Toolbar.js deleted file mode 100644 index 5e5e5721b63..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/module/Toolbar.js +++ /dev/null @@ -1,101 +0,0 @@ -define([ - 'summernote/core/list', - 'summernote/core/dom', - 'summernote/module/Button' -], function (list, dom, Button) { - /** - * @class module.Toolbar - * - * Toolbar - */ - var Toolbar = function () { - var button = new Button(); - - this.button = button; // ODOO: allow access for override - - this.update = function ($toolbar, styleInfo) { - button.update($toolbar, styleInfo); - }; - - /** - * @param {Node} button - * @param {String} eventName - * @param {String} value - */ - this.updateRecentColor = function (buttonNode, eventName, value) { - button.updateRecentColor(buttonNode, eventName, value); - }; - - /** - * activate buttons exclude codeview - * @param {jQuery} $toolbar - */ - this.activate = function ($toolbar) { - $toolbar.find('button') - .not('button[data-event="codeview"]') - .removeClass('disabled'); - }; - - /** - * deactivate buttons exclude codeview - * @param {jQuery} $toolbar - */ - this.deactivate = function ($toolbar) { - $toolbar.find('button') - .not('button[data-event="codeview"]') - .addClass('disabled'); - }; - - /** - * @param {jQuery} $container - * @param {Boolean} [bFullscreen=false] - */ - this.updateFullscreen = function ($container, bFullscreen) { - var $btn = $container.find('button[data-event="fullscreen"]'); - $btn.toggleClass('active', bFullscreen); - }; - - /** - * @param {jQuery} $container - * @param {Boolean} [isCodeview=false] - */ - this.updateCodeview = function ($container, isCodeview) { - var $btn = $container.find('button[data-event="codeview"]'); - $btn.toggleClass('active', isCodeview); - - if (isCodeview) { - this.deactivate($container); - } else { - this.activate($container); - } - }; - - /** - * get button in toolbar - * - * @param {jQuery} $editable - * @param {String} name - * @return {jQuery} - */ - this.get = function ($editable, name) { - var $toolbar = dom.makeLayoutInfo($editable).toolbar(); - - return $toolbar.find('[data-name=' + name + ']'); - }; - - /** - * set button state - * @param {jQuery} $editable - * @param {String} name - * @param {Boolean} [isActive=true] - */ - this.setButtonState = function ($editable, name, isActive) { - isActive = (isActive === false) ? false : true; - - var $button = this.get($editable, name); - $button.toggleClass('active', isActive); - }; - }; - - return Toolbar; -}); diff --git a/addons/web_editor/static/lib/summernote/src/js/outro.js b/addons/web_editor/static/lib/summernote/src/js/outro.js deleted file mode 100644 index be4600a5cb1..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/outro.js +++ /dev/null @@ -1 +0,0 @@ -})); diff --git a/addons/web_editor/static/lib/summernote/src/js/summernote.js b/addons/web_editor/static/lib/summernote/src/js/summernote.js deleted file mode 100644 index 329ba7fa126..00000000000 --- a/addons/web_editor/static/lib/summernote/src/js/summernote.js +++ /dev/null @@ -1,328 +0,0 @@ -define([ - 'summernote/core/agent', - 'summernote/core/list', - 'summernote/core/dom', - 'summernote/core/range', - 'summernote/defaults', - 'summernote/EventHandler', - 'summernote/Renderer', - 'summernote/core/key' // ODOO: change for override -], function (agent, list, dom, range, - defaults, EventHandler, Renderer, key) { - - // jQuery namespace for summernote - /** - * @class $.summernote - * - * summernote attribute - * - * @mixin defaults - * @singleton - * - */ - $.summernote = $.summernote || {}; - - // extends default settings - // - $.summernote.version - // - $.summernote.options - // - $.summernote.lang - $.extend($.summernote, defaults); - - var renderer = new Renderer(); - var eventHandler = new EventHandler(); - - $.extend($.summernote, { - /** @property {Renderer} */ - renderer: renderer, - /** @property {EventHandler} */ - eventHandler: eventHandler, - /** - * @property {Object} core - * @property {core.agent} core.agent - * @property {core.dom} core.dom - * @property {core.range} core.range - */ - core: { - agent: agent, - list : list, - dom: dom, - range: range, - key: key // ODOO: change for override - }, - /** - * @property {Object} - * pluginEvents event list for plugins - * event has name and callback function. - * - * ``` - * $.summernote.addPlugin({ - * events : { - * 'hello' : function(layoutInfo, value, $target) { - * console.log('event name is hello, value is ' + value ); - * } - * } - * }) - * ``` - * - * * event name is data-event property. - * * layoutInfo is a summernote layout information. - * * value is data-value property. - */ - pluginEvents: {}, - - plugins : [] - }); - - /** - * @method addPlugin - * - * add Plugin in Summernote - * - * Summernote can make a own plugin. - * - * ### Define plugin - * ``` - * // get template function - * var tmpl = $.summernote.renderer.getTemplate(); - * - * // add a button - * $.summernote.addPlugin({ - * buttons : { - * // "hello" is button's namespace. - * "hello" : function(lang, options) { - * // make icon button by template function - * return tmpl.iconButton(options.iconPrefix + 'header', { - * // callback function name when button clicked - * event : 'hello', - * // set data-value property - * value : 'hello', - * hide : true - * }); - * } - * - * }, - * - * events : { - * "hello" : function(layoutInfo, value) { - * // here is event code - * } - * } - * }); - * ``` - * ### Use a plugin in toolbar - * - * ``` - * $("#editor").summernote({ - * ... - * toolbar : [ - * // display hello plugin in toolbar - * ['group', [ 'hello' ]] - * ] - * ... - * }); - * ``` - * - * - * @param {Object} plugin - * @param {Object} [plugin.buttons] define plugin button. for detail, see to Renderer.addButtonInfo - * @param {Object} [plugin.dialogs] define plugin dialog. for detail, see to Renderer.addDialogInfo - * @param {Object} [plugin.events] add event in $.summernote.pluginEvents - * @param {Object} [plugin.langs] update $.summernote.lang - * @param {Object} [plugin.options] update $.summernote.options - */ - $.summernote.addPlugin = function (plugin) { - - // save plugin list - $.summernote.plugins.push(plugin); - - if (plugin.buttons) { - $.each(plugin.buttons, function (name, button) { - renderer.addButtonInfo(name, button); - }); - } - - if (plugin.dialogs) { - $.each(plugin.dialogs, function (name, dialog) { - renderer.addDialogInfo(name, dialog); - }); - } - - if (plugin.events) { - $.each(plugin.events, function (name, event) { - $.summernote.pluginEvents[name] = event; - }); - } - - if (plugin.langs) { - $.each(plugin.langs, function (locale, lang) { - if ($.summernote.lang[locale]) { - $.extend($.summernote.lang[locale], lang); - } - }); - } - - if (plugin.options) { - $.extend($.summernote.options, plugin.options); - } - }; - - /* - * extend $.fn - */ - $.fn.extend({ - /** - * @method - * Initialize summernote - * - create editor layout and attach Mouse and keyboard events. - * - * ``` - * $("#summernote").summernote( { options ..} ); - * ``` - * - * @member $.fn - * @param {Object|String} options reference to $.summernote.options - * @return {this} - */ - summernote: function () { - // check first argument's type - // - {String}: External API call {{module}}.{{method}} - // - {Object}: init options - var type = $.type(list.head(arguments)); - var isExternalAPICalled = type === 'string'; - var hasInitOptions = type === 'object'; - - // extend default options with custom user options - var options = hasInitOptions ? list.head(arguments) : {}; - - options = $.extend({}, $.summernote.options, options); - options.icons = $.extend({}, $.summernote.options.icons, options.icons); - - // Include langInfo in options for later use, e.g. for image drag-n-drop - // Setup language info with en-US as default - options.langInfo = $.extend(true, {}, $.summernote.lang['en-US'], $.summernote.lang[options.lang]); - - // override plugin options - if (!isExternalAPICalled && hasInitOptions) { - for (var i = 0, len = $.summernote.plugins.length; i < len; i++) { - var plugin = $.summernote.plugins[i]; - - if (options.plugin[plugin.name]) { - $.summernote.plugins[i] = $.extend(true, plugin, options.plugin[plugin.name]); - } - } - } - - this.each(function (idx, holder) { - var $holder = $(holder); - - // if layout isn't created yet, createLayout and attach events - if (!renderer.hasNoteEditor($holder)) { - renderer.createLayout($holder, options); - - var layoutInfo = renderer.layoutInfoFromHolder($holder); - $holder.data('layoutInfo', layoutInfo); - - eventHandler.attach(layoutInfo, options); - eventHandler.attachCustomEvent(layoutInfo, options); - } - }); - - var $first = this.first(); - if ($first.length) { - var layoutInfo = renderer.layoutInfoFromHolder($first); - - // external API - if (isExternalAPICalled) { - var moduleAndMethod = list.head(list.from(arguments)); - var args = list.tail(list.from(arguments)); - - // TODO now external API only works for editor - var params = [moduleAndMethod, layoutInfo.editable()].concat(args); - return eventHandler.invoke.apply(eventHandler, params); - } else if (options.focus) { - // focus on first editable element for initialize editor - layoutInfo.editable().focus(); - } - } - - return this; - }, - - /** - * @method - * - * get the HTML contents of note or set the HTML contents of note. - * - * * get contents - * ``` - * var content = $("#summernote").code(); - * ``` - * * set contents - * - * ``` - * $("#summernote").code(html); - * ``` - * - * @member $.fn - * @param {String} [html] - HTML contents(optional, set) - * @return {this|String} - context(set) or HTML contents of note(get). - */ - code: function (html) { - // get the HTML contents of note - if (html === undefined) { - var $holder = this.first(); - if (!$holder.length) { - return; - } - - var layoutInfo = renderer.layoutInfoFromHolder($holder); - var $editable = layoutInfo && layoutInfo.editable(); - - if ($editable && $editable.length) { - var isCodeview = eventHandler.invoke('codeview.isActivated', layoutInfo); - eventHandler.invoke('codeview.sync', layoutInfo); - return isCodeview ? layoutInfo.codable().val() : - layoutInfo.editable().html(); - } - return dom.value($holder); - } - - // set the HTML contents of note - this.each(function (i, holder) { - var layoutInfo = renderer.layoutInfoFromHolder($(holder)); - var $editable = layoutInfo && layoutInfo.editable(); - if ($editable) { - $editable.html(html); - } - }); - - return this; - }, - - /** - * @method - * - * destroy Editor Layout and detach Key and Mouse Event - * - * @member $.fn - * @return {this} - */ - destroy: function () { - this.each(function (idx, holder) { - var $holder = $(holder); - - if (!renderer.hasNoteEditor($holder)) { - return; - } - - var info = renderer.layoutInfoFromHolder($holder); - var options = info.editor().data('options'); - - eventHandler.detach(info, options); - renderer.removeLayout($holder, info, options); - }); - - return this; - } - }); -}); diff --git a/addons/web_editor/static/lib/summernote/src/less/summernote.less b/addons/web_editor/static/lib/summernote/src/less/summernote.less deleted file mode 100644 index dc8da25122d..00000000000 --- a/addons/web_editor/static/lib/summernote/src/less/summernote.less +++ /dev/null @@ -1,467 +0,0 @@ -/* Theme Variables - ------------------------------------------*/ -@border-color: #a9a9a9; -@background-color: #f5f5f5; - -/* Frame Mode Layout - ------------------------------------------*/ -.note-editor { - border: 1px solid @border-color; - position: relative; - /* overflow: hidden; ODOO: removed for embedded editor */ - - /* dropzone */ - @dropzone-color: lightskyblue; - @dropzone-active-color: darken(@dropzone-color, 30); - .note-dropzone { - position: absolute; - display: none; - z-index: 100; - color: @dropzone-color; - background-color: white; - opacity: 0.95; - pointer-event: none; - - .note-dropzone-message { - display: table-cell; - vertical-align: middle; - text-align: center; - font-size: 28px; - font-weight: bold; - } - - &.hover { - color: @dropzone-active-color; - } - } - - &.dragover .note-dropzone { - display: table; - } - - /* codeview mode */ - &.codeview { - .note-editing-area { - .note-editable { - display: none; - } - .note-codable { - display: block; - } - } - } - - /* fullscreen mode */ - &.fullscreen { - position: fixed; - top: 0; - left: 0; - width: 100%; - z-index: 1050; /* bs3 modal-backdrop: 1030, bs2: 1040 */ - .note-editable { - background-color: white; - } - .note-resizebar { - display: none; - } - } - - .note-editing-area { - position: relative; - overflow: hidden; - - /* editable */ - .note-editable { - background-color: #fff; - color: #000; - padding: 10px; - overflow: auto; - outline: none; - - &[contenteditable=true]:empty:not(:focus):before { - content:attr(data-placeholder); - } - &[contenteditable="false"] { - background-color: #e5e5e5; - } - } - - /* codeable */ - .note-codable { - display: none; - width: 100%; - padding: 10px; - border: none; - box-shadow: none; - font-family: Menlo, Monaco, monospace, sans-serif; - font-size: 14px; - color: #ccc; - background-color: #222; - resize: none; - - /* override BS2 default style */ - box-sizing: border-box; - border-radius: 0; - margin-bottom: 0; - } - } - - /* statusbar */ - .note-statusbar { - background-color: @background-color; - .note-resizebar { - padding-top: 1px; - height: 8px; - width: 100%; - cursor: ns-resize; - .note-icon-bar { - width: 20px; - margin: 1px auto; - border-top: 1px solid @border-color; - } - } - } -} - -/* Air Mode Layout -------------------------------------------*/ -.note-air-editor { - outline: none; -} - -/* Popover -------------------------------------------*/ -.note-popover .popover { - max-width: none; - .popover-body { - a { - display: inline-block; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; /* for FF */ - vertical-align: middle; /* for FF */ - } - } - & .arrow { - left: 20px; - } -} - -/* Popover and Toolbar (Button container) -------------------------------------------*/ -.note-popover .popover .popover-body, .panel-heading.note-toolbar { - margin: 0; - padding: 0 0 5px 5px; - - &>.btn-group { - margin-top: 5px; - margin-left: 0; - margin-right: 5px; - } - - .btn-group { - .note-table { - min-width: 0; - padding: 5px; - .note-dimension-picker { - font-size: 18px; - .note-dimension-picker-mousecatcher { - position: absolute !important; - z-index: 3; - width: 10em; - height: 10em; - cursor: pointer; - } - .note-dimension-picker-unhighlighted { - position: relative !important; - z-index: 1; - width: 5em; - height: 5em; - background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIj4+Pjp6ekKlAqjAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKhmnaJzPAAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat; - } - .note-dimension-picker-highlighted { - position: absolute !important; - z-index: 2; - width: 1em; - height: 1em; - background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIjd6vvD2f9LKLW+AAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKwNDEVT0AAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat; - } - } - } - } - - .note-style { - h1, h2, h3, h4, h5, h6, blockquote { - margin: 0; - } - } - - .note-color { - .dropdown-toggle { - width: 20px; - padding-left: 5px; - } - .dropdown-menu { - min-width: 340px; - .btn-group { - margin: 0; - &:first-child { - margin: 0 5px; - } - - .note-palette-title { - font-size: 12px; - margin: 2px 7px; - text-align: center; - border-bottom: 1px solid #eee; - } - - .note-color-reset { - font-size: 11px; - margin: 3px; - padding: 0 3px; - cursor: pointer; - border-radius: 5px; - } - - .note-color-row { - height: 20px; - } - - .note-color-reset:hover { - background: #eee; - } - } - } - } - - .note-para { - .dropdown-menu { - min-width: 216px; - padding: 5px; - &>div:first-child { - margin-right: 5px; - } - } - } - - /* dropdown-menu for toolbar and popover */ - .dropdown-menu { - min-width: 90px; - - /* dropdown-menu right position */ - /* http://forrst.com/posts/Bootstrap_right_positioned_dropdown-2KB */ - &.right { - right: 0; - left: auto; - &::before { right: 9px; left: auto !important; } - &::after { right: 10px; left: auto !important; } - } - /* dropdown-menu for selectbox */ - &.note-check { - li a i { - color: deepskyblue; - visibility: hidden; - } - li a.checked i { - visibility: visible; - } - } - - } - - .note-fontsize-10 { - font-size: 10px; - } - - /* color palette for toolbar and popover */ - .note-color-palette { - line-height: 1; - div { - .note-color-btn { - width: 20px; - height: 20px; - padding: 0; - margin: 0; - border: 1px solid #fff; - } - .note-color-btn:hover { - border: 1px solid #000; - } - } - } -} - -/* Dialog -------------------------------------------*/ -.note-dialog { - &>div { - display: none; /* BS2's hide pacth. */ - } - .form-group { /* overwrite BS's form-horizontal minus margins */ - margin-left: 0; - margin-right: 0; - } - .note-modal-form { - margin: 0; /* overwrite BS2's form margin bottom */ - } - .note-image-dialog { - .note-dropzone { - min-height: 100px; - font-size: 30px; - line-height: 4; /* vertical-align */ - color: lightgray; - text-align: center; - border: 4px dashed lightgray; - margin-bottom: 10px; - } - } - .note-help-dialog { - @note-shortcut-hl: #dd0; - font-size: 12px; - color: #ccc; - background-color: #222 !important; - opacity: 0.9; - .modal-content { - background: transparent; - border: 1px solid white; - box-shadow: none; - border-radius: 5px; - } - - /* BS2's background pacth. */ - background: transparent; - border: none; - - a { - font-size: 12px; - color: white; - } - - .title { - color: white; - font-size: 14px; - font-weight: bold; - padding-bottom: 5px; - margin-bottom: 10px; - border-bottom: white 1px solid; - } - - .modal-close { - font-size: 14px; - color: @note-shortcut-hl; - cursor: pointer; - } - - .text-center { - margin: 10px 0 0; - } - - .note-shortcut { - padding-top: 8px; - padding-bottom: 8px; - - &-row { - margin-right: -5px; - margin-left: -5px; - } - - &-col { - padding-right: 5px; - padding-left: 5px; - } - - &-title { - font-size: 13px; - font-weight: bold; - color: @note-shortcut-hl; - } - - &-key { - font-family: "Courier New"; - color: @note-shortcut-hl; - text-align: right; - } - } - } -} - -/* Handle -------------------------------------------*/ -.note-handle { - /* control selection */ - .note-control-selection { - position: absolute; - display: none; - border: 1px solid black; - &>div { position: absolute; } - - .note-control-selection-bg { - width: 100%; - height: 100%; - background-color: black; - opacity: 0.3; - } - - .note-control-handle { - width: 7px; - height: 7px; - border: 1px solid black; - } - - .note-control-holder { - .note-control-handle; - } - - .note-control-sizing { - .note-control-handle; - background-color: white; - } - - .note-control-nw { - top: -5px; - left: -5px; - border-right: none; - border-bottom: none; - } - - .note-control-ne { - top: -5px; - right: -5px; - border-bottom: none; - border-left: none; - } - - .note-control-sw { - bottom: -5px; - left: -5px; - border-top: none; - border-right: none; - } - - .note-control-se { - right: -5px; - bottom: -5px; - cursor: se-resize; - } - - .note-control-se.note-control-holder { - cursor: default; - border-top: none; - border-left: none; - } - - .note-control-selection-info { - right: 0; - bottom: 0; - padding: 5px; - margin: 5px; - color: white; - background-color: black; - font-size: 12px; - border-radius: 5px; - opacity: 0.7; - } - } -} diff --git a/addons/web_editor/static/lib/summernote/summernote.css b/addons/web_editor/static/lib/summernote/summernote.css new file mode 100644 index 00000000000..85492bd24e5 --- /dev/null +++ b/addons/web_editor/static/lib/summernote/summernote.css @@ -0,0 +1 @@ +@font-face{font-family:"summernote";font-style:normal;font-weight:normal;src:url("./font/summernote.eot?e557617934c52ea068954af79ed7c221");src:url("./font/summernote.eot?#iefix") format("embedded-opentype"),url("./font/summernote.woff?e557617934c52ea068954af79ed7c221") format("woff"),url("./font/summernote.ttf?e557617934c52ea068954af79ed7c221") format("truetype")}[class^="note-icon-"]:before,[class*=" note-icon-"]:before{display:inline-block;font:normal normal normal 14px summernote;font-size:inherit;-webkit-font-smoothing:antialiased;text-decoration:inherit;text-rendering:auto;text-transform:none;vertical-align:middle;speak:none;-moz-osx-font-smoothing:grayscale}.note-icon-align-center:before,.note-icon-align-indent:before,.note-icon-align-justify:before,.note-icon-align-left:before,.note-icon-align-outdent:before,.note-icon-align-right:before,.note-icon-align:before,.note-icon-arrow-circle-down:before,.note-icon-arrow-circle-left:before,.note-icon-arrow-circle-right:before,.note-icon-arrow-circle-up:before,.note-icon-arrows-alt:before,.note-icon-arrows-h:before,.note-icon-arrows-v:before,.note-icon-bold:before,.note-icon-caret:before,.note-icon-chain-broken:before,.note-icon-circle:before,.note-icon-close:before,.note-icon-code:before,.note-icon-col-after:before,.note-icon-col-before:before,.note-icon-col-remove:before,.note-icon-eraser:before,.note-icon-font:before,.note-icon-frame:before,.note-icon-italic:before,.note-icon-link:before,.note-icon-magic:before,.note-icon-menu-check:before,.note-icon-minus:before,.note-icon-orderedlist:before,.note-icon-pencil:before,.note-icon-picture:before,.note-icon-question:before,.note-icon-redo:before,.note-icon-row-above:before,.note-icon-row-below:before,.note-icon-row-remove:before,.note-icon-special-character:before,.note-icon-square:before,.note-icon-strikethrough:before,.note-icon-subscript:before,.note-icon-summernote:before,.note-icon-superscript:before,.note-icon-table:before,.note-icon-text-height:before,.note-icon-trash:before,.note-icon-underline:before,.note-icon-undo:before,.note-icon-unorderedlist:before,.note-icon-video:before{display:inline-block;font-family:"summernote";font-style:normal;font-weight:normal;text-decoration:inherit}.note-icon-align-center:before{content:"\f101"}.note-icon-align-indent:before{content:"\f102"}.note-icon-align-justify:before{content:"\f103"}.note-icon-align-left:before{content:"\f104"}.note-icon-align-outdent:before{content:"\f105"}.note-icon-align-right:before{content:"\f106"}.note-icon-align:before{content:"\f107"}.note-icon-arrow-circle-down:before{content:"\f108"}.note-icon-arrow-circle-left:before{content:"\f109"}.note-icon-arrow-circle-right:before{content:"\f10a"}.note-icon-arrow-circle-up:before{content:"\f10b"}.note-icon-arrows-alt:before{content:"\f10c"}.note-icon-arrows-h:before{content:"\f10d"}.note-icon-arrows-v:before{content:"\f10e"}.note-icon-bold:before{content:"\f10f"}.note-icon-caret:before{content:"\f110"}.note-icon-chain-broken:before{content:"\f111"}.note-icon-circle:before{content:"\f112"}.note-icon-close:before{content:"\f113"}.note-icon-code:before{content:"\f114"}.note-icon-col-after:before{content:"\f115"}.note-icon-col-before:before{content:"\f116"}.note-icon-col-remove:before{content:"\f117"}.note-icon-eraser:before{content:"\f118"}.note-icon-font:before{content:"\f119"}.note-icon-frame:before{content:"\f11a"}.note-icon-italic:before{content:"\f11b"}.note-icon-link:before{content:"\f11c"}.note-icon-magic:before{content:"\f11d"}.note-icon-menu-check:before{content:"\f11e"}.note-icon-minus:before{content:"\f11f"}.note-icon-orderedlist:before{content:"\f120"}.note-icon-pencil:before{content:"\f121"}.note-icon-picture:before{content:"\f122"}.note-icon-question:before{content:"\f123"}.note-icon-redo:before{content:"\f124"}.note-icon-row-above:before{content:"\f125"}.note-icon-row-below:before{content:"\f126"}.note-icon-row-remove:before{content:"\f127"}.note-icon-special-character:before{content:"\f128"}.note-icon-square:before{content:"\f129"}.note-icon-strikethrough:before{content:"\f12a"}.note-icon-subscript:before{content:"\f12b"}.note-icon-summernote:before{content:"\f12c"}.note-icon-superscript:before{content:"\f12d"}.note-icon-table:before{content:"\f12e"}.note-icon-text-height:before{content:"\f12f"}.note-icon-trash:before{content:"\f130"}.note-icon-underline:before{content:"\f131"}.note-icon-undo:before{content:"\f132"}.note-icon-unorderedlist:before{content:"\f133"}.note-icon-video:before{content:"\f134"}.note-editor{position:relative}.note-editor .note-dropzone{position:absolute;z-index:100;display:none;color:#87cefa;background-color:white;opacity:.95}.note-editor .note-dropzone .note-dropzone-message{display:table-cell;font-size:28px;font-weight:bold;text-align:center;vertical-align:middle}.note-editor .note-dropzone.hover{color:#098ddf}.note-editor.dragover .note-dropzone{display:table}.note-editor .note-editing-area{position:relative}.note-editor .note-editing-area .note-editable{outline:0}.note-editor .note-editing-area .note-editable sup{vertical-align:super}.note-editor .note-editing-area .note-editable sub{vertical-align:sub}.note-editor .note-editing-area img.note-float-left{margin-right:10px}.note-editor .note-editing-area img.note-float-right{margin-left:10px}.note-editor.note-frame{border:1px solid #a9a9a9}.note-editor.note-frame.codeview .note-editing-area .note-editable{display:none}.note-editor.note-frame.codeview .note-editing-area .note-codable{display:block}.note-editor.note-frame .note-editing-area{overflow:hidden}.note-editor.note-frame .note-editing-area .note-editable{padding:10px;overflow:auto;color:#000;word-wrap:break-word;background-color:#fff}.note-editor.note-frame .note-editing-area .note-editable[contenteditable="false"]{background-color:#e5e5e5}.note-editor.note-frame .note-editing-area .note-codable{display:none;width:100%;padding:10px;margin-bottom:0;font-family:Menlo,Monaco,monospace,sans-serif;font-size:14px;color:#ccc;background-color:#222;border:0;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;box-shadow:none;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box;resize:none}.note-editor.note-frame.fullscreen{position:fixed;top:0;left:0;z-index:1050;width:100%!important}.note-editor.note-frame.fullscreen .note-editable{background-color:white}.note-editor.note-frame.fullscreen .note-resizebar{display:none}.note-editor.note-frame .note-statusbar{background-color:#f5f5f5;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.note-editor.note-frame .note-statusbar .note-resizebar{width:100%;height:8px;padding-top:1px;cursor:ns-resize}.note-editor.note-frame .note-statusbar .note-resizebar .note-icon-bar{width:20px;margin:1px auto;border-top:1px solid #a9a9a9}.note-editor.note-frame .note-placeholder{padding:10px}.note-popover.popover{display:none;max-width:none}.note-popover.popover .popover-content a{display:inline-block;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.note-popover.popover .arrow{left:20px!important}.note-toolbar{position:relative;z-index:500}.note-popover .popover-content,.card-header.note-toolbar{padding:0 0 5px 5px;margin:0}.note-popover .popover-content>.btn-group,.card-header.note-toolbar>.btn-group{margin-top:5px;margin-right:5px;margin-left:0}.note-popover .popover-content .btn-group .note-table,.card-header.note-toolbar .btn-group .note-table{min-width:0;padding:5px}.note-popover .popover-content .btn-group .note-table .note-dimension-picker,.card-header.note-toolbar .btn-group .note-table .note-dimension-picker{font-size:18px}.note-popover .popover-content .btn-group .note-table .note-dimension-picker .note-dimension-picker-mousecatcher,.card-header.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-mousecatcher{position:absolute!important;z-index:3;width:10em;height:10em;cursor:pointer}.note-popover .popover-content .btn-group .note-table .note-dimension-picker .note-dimension-picker-unhighlighted,.card-header.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-unhighlighted{position:relative!important;z-index:1;width:5em;height:5em;background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIj4+Pjp6ekKlAqjAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKhmnaJzPAAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat}.note-popover .popover-content .btn-group .note-table .note-dimension-picker .note-dimension-picker-highlighted,.card-header.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-highlighted{position:absolute!important;z-index:2;width:1em;height:1em;background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIjd6vvD2f9LKLW+AAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKwNDEVT0AAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat}.note-popover .popover-content .note-style .dropdown-style blockquote,.card-header.note-toolbar .note-style .dropdown-style blockquote,.note-popover .popover-content .note-style .dropdown-style pre,.card-header.note-toolbar .note-style .dropdown-style pre{padding:5px 10px;margin:0}.note-popover .popover-content .note-style .dropdown-style h1,.card-header.note-toolbar .note-style .dropdown-style h1,.note-popover .popover-content .note-style .dropdown-style h2,.card-header.note-toolbar .note-style .dropdown-style h2,.note-popover .popover-content .note-style .dropdown-style h3,.card-header.note-toolbar .note-style .dropdown-style h3,.note-popover .popover-content .note-style .dropdown-style h4,.card-header.note-toolbar .note-style .dropdown-style h4,.note-popover .popover-content .note-style .dropdown-style h5,.card-header.note-toolbar .note-style .dropdown-style h5,.note-popover .popover-content .note-style .dropdown-style h6,.card-header.note-toolbar .note-style .dropdown-style h6,.note-popover .popover-content .note-style .dropdown-style p,.card-header.note-toolbar .note-style .dropdown-style p{padding:0;margin:0}.note-popover .popover-content .note-color .dropdown-toggle,.card-header.note-toolbar .note-color .dropdown-toggle{width:20px;padding-left:5px}.note-popover .popover-content .note-color .dropdown-menu,.card-header.note-toolbar .note-color .dropdown-menu{min-width:337px}.note-popover .popover-content .note-color .dropdown-menu .note-palette,.card-header.note-toolbar .note-color .dropdown-menu .note-palette{display:inline-block;width:160px;margin:0}.note-popover .popover-content .note-color .dropdown-menu .note-palette:first-child,.card-header.note-toolbar .note-color .dropdown-menu .note-palette:first-child{margin:0 5px}.note-popover .popover-content .note-color .dropdown-menu .note-palette .note-palette-title,.card-header.note-toolbar .note-color .dropdown-menu .note-palette .note-palette-title{margin:2px 7px;font-size:12px;text-align:center;border-bottom:1px solid #eee}.note-popover .popover-content .note-color .dropdown-menu .note-palette .note-color-reset,.card-header.note-toolbar .note-color .dropdown-menu .note-palette .note-color-reset{width:100%;padding:0 3px;margin:3px;font-size:11px;cursor:pointer;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px}.note-popover .popover-content .note-color .dropdown-menu .note-palette .note-color-row,.card-header.note-toolbar .note-color .dropdown-menu .note-palette .note-color-row{height:20px}.note-popover .popover-content .note-color .dropdown-menu .note-palette .note-color-reset:hover,.card-header.note-toolbar .note-color .dropdown-menu .note-palette .note-color-reset:hover{background:#eee}.note-popover .popover-content .note-para .dropdown-menu,.card-header.note-toolbar .note-para .dropdown-menu{min-width:216px;padding:5px}.note-popover .popover-content .note-para .dropdown-menu>div:first-child,.card-header.note-toolbar .note-para .dropdown-menu>div:first-child{margin-right:5px}.note-popover .popover-content .dropdown-menu,.card-header.note-toolbar .dropdown-menu{min-width:90px}.note-popover .popover-content .dropdown-menu.right,.card-header.note-toolbar .dropdown-menu.right{right:0;left:auto}.note-popover .popover-content .dropdown-menu.right::before,.card-header.note-toolbar .dropdown-menu.right::before{right:9px;left:auto!important}.note-popover .popover-content .dropdown-menu.right::after,.card-header.note-toolbar .dropdown-menu.right::after{right:10px;left:auto!important}.note-popover .popover-content .dropdown-menu.note-check a i,.card-header.note-toolbar .dropdown-menu.note-check a i{color:deepskyblue;visibility:hidden}.note-popover .popover-content .dropdown-menu.note-check a.checked i,.card-header.note-toolbar .dropdown-menu.note-check a.checked i{visibility:visible}.note-popover .popover-content .note-fontsize-10,.card-header.note-toolbar .note-fontsize-10{font-size:10px}.note-popover .popover-content .note-color-palette,.card-header.note-toolbar .note-color-palette{line-height:1}.note-popover .popover-content .note-color-palette div .note-color-btn,.card-header.note-toolbar .note-color-palette div .note-color-btn{width:20px;height:20px;padding:0;margin:0;border:1px solid #fff}.note-popover .popover-content .note-color-palette div .note-color-btn:hover,.card-header.note-toolbar .note-color-palette div .note-color-btn:hover{border:1px solid #000}.note-dialog>div{display:none}.note-dialog .form-group{margin-right:0;margin-left:0}.note-dialog .note-modal-form{margin:0}.note-dialog .note-image-dialog .note-dropzone{min-height:100px;margin-bottom:10px;font-size:30px;line-height:4;color:lightgray;text-align:center;border:4px dashed lightgray}@-moz-document url-prefix(){.note-image-input{height:auto}}.note-placeholder{position:absolute;display:none;color:gray}.note-handle .note-control-selection{position:absolute;display:none;border:1px solid black}.note-handle .note-control-selection>div{position:absolute}.note-handle .note-control-selection .note-control-selection-bg{width:100%;height:100%;background-color:black;-webkit-opacity:.3;-khtml-opacity:.3;-moz-opacity:.3;opacity:.3;-ms-filter:alpha(opacity=30);filter:alpha(opacity=30)}.note-handle .note-control-selection .note-control-handle{width:7px;height:7px;border:1px solid black}.note-handle .note-control-selection .note-control-holder{width:7px;height:7px;border:1px solid black}.note-handle .note-control-selection .note-control-sizing{width:7px;height:7px;background-color:white;border:1px solid black}.note-handle .note-control-selection .note-control-nw{top:-5px;left:-5px;border-right:0;border-bottom:0}.note-handle .note-control-selection .note-control-ne{top:-5px;right:-5px;border-bottom:0;border-left:none}.note-handle .note-control-selection .note-control-sw{bottom:-5px;left:-5px;border-top:0;border-right:0}.note-handle .note-control-selection .note-control-se{right:-5px;bottom:-5px;cursor:se-resize}.note-handle .note-control-selection .note-control-se.note-control-holder{cursor:default;border-top:0;border-left:none}.note-handle .note-control-selection .note-control-selection-info{right:0;bottom:0;padding:5px;margin:5px;font-size:12px;color:white;background-color:black;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;-webkit-opacity:.7;-khtml-opacity:.7;-moz-opacity:.7;opacity:.7;-ms-filter:alpha(opacity=70);filter:alpha(opacity=70)}.note-hint-popover{min-width:100px;padding:2px}.note-hint-popover .popover-content{max-height:150px;padding:3px;overflow:auto}.note-hint-popover .popover-content .note-hint-group .note-hint-item{display:block!important;padding:3px}.note-hint-popover .popover-content .note-hint-group .note-hint-item.active,.note-hint-popover .popover-content .note-hint-group .note-hint-item:hover{display:block;clear:both;font-weight:400;line-height:1.4;color:white;text-decoration:none;white-space:nowrap;cursor:pointer;background-color:#428bca;outline:0}
\ No newline at end of file diff --git a/addons/web_editor/static/lib/summernote/summernote.js b/addons/web_editor/static/lib/summernote/summernote.js new file mode 100644 index 00000000000..2bcb61bb873 --- /dev/null +++ b/addons/web_editor/static/lib/summernote/summernote.js @@ -0,0 +1,7288 @@ +/** + * Super simple wysiwyg editor v0.8.9 + * https://summernote.org + * + * Copyright 2013- Alan Hong. and other contributors + * summernote may be freely distributed under the MIT license. + * + * Date: 2017-12-25T06:39Z + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('jquery')) : + typeof define === 'function' && define.amd ? define(['jquery'], factory) : + (factory(global.jQuery)); +}(this, (function ($$1) { 'use strict'; + +$$1 = $$1 && $$1.hasOwnProperty('default') ? $$1['default'] : $$1; + +var Renderer = /** @class */ (function () { + function Renderer(markup, children, options, callback) { + this.markup = markup; + this.children = children; + this.options = options; + this.callback = callback; + } + Renderer.prototype.render = function ($parent) { + var $node = $$1(this.markup); + if (this.options && this.options.contents) { + $node.html(this.options.contents); + } + if (this.options && this.options.className) { + $node.addClass(this.options.className); + } + if (this.options && this.options.data) { + $$1.each(this.options.data, function (k, v) { + $node.attr('data-' + k, v); + }); + } + if (this.options && this.options.click) { + $node.on('click', this.options.click); + } + if (this.children) { + var $container_1 = $node.find('.note-children-container'); + this.children.forEach(function (child) { + child.render($container_1.length ? $container_1 : $node); + }); + } + if (this.callback) { + this.callback($node, this.options); + } + if (this.options && this.options.callback) { + this.options.callback($node); + } + if ($parent) { + $parent.append($node); + } + return $node; + }; + return Renderer; +}()); +var renderer = { + create: function (markup, callback) { + return function () { + var options = typeof arguments[1] === 'object' ? arguments[1] : arguments[0]; + var children = $$1.isArray(arguments[0]) ? arguments[0] : []; + if (options && options.children) { + children = options.children; + } + return new Renderer(markup, children, options, callback); + }; + } +}; + +var editor = renderer.create('<div class="note-editor note-frame card"/>'); +var toolbar = renderer.create('<div class="note-toolbar-wrapper"><div class="note-toolbar card-header"></div></div>'); +var editingArea = renderer.create('<div class="note-editing-area"/>'); +var codable = renderer.create('<textarea class="note-codable"/>'); +var editable = renderer.create('<div class="note-editable card-block" contentEditable="true"/>'); +var statusbar = renderer.create([ + '<div class="note-statusbar">', + ' <div class="note-resizebar">', + ' <div class="note-icon-bar"/>', + ' <div class="note-icon-bar"/>', + ' <div class="note-icon-bar"/>', + ' </div>', + '</div>' +].join('')); +var airEditor = renderer.create('<div class="note-editor"/>'); +var airEditable = renderer.create('<div class="note-editable" contentEditable="true"/>'); +var buttonGroup = renderer.create('<div class="note-btn-group btn-group">'); +var dropdown = renderer.create('<div class="dropdown-menu">', function ($node, options) { + var markup = $$1.isArray(options.items) ? options.items.map(function (item) { + var value = (typeof item === 'string') ? item : (item.value || ''); + var content = options.template ? options.template(item) : item; + var option = (typeof item === 'object') ? item.option : undefined; + var dataValue = 'data-value="' + value + '"'; + var dataOption = (option !== undefined) ? ' data-option="' + option + '"' : ''; + return '<a class="dropdown-item" href="#" ' + (dataValue + dataOption) + '>' + content + '</a>'; + }).join('') : options.items; + $node.html(markup); +}); +var dropdownButtonContents = function (contents) { + return contents; +}; +var dropdownCheck = renderer.create('<div class="dropdown-menu note-check">', function ($node, options) { + var markup = $$1.isArray(options.items) ? options.items.map(function (item) { + var value = (typeof item === 'string') ? item : (item.value || ''); + var content = options.template ? options.template(item) : item; + return '<a class="dropdown-item" href="#" data-value="' + value + '">' + icon(options.checkClassName) + ' ' + content + '</a>'; + }).join('') : options.items; + $node.html(markup); +}); +var palette = renderer.create('<div class="note-color-palette"/>', function ($node, options) { + var contents = []; + for (var row = 0, rowSize = options.colors.length; row < rowSize; row++) { + var eventName = options.eventName; + var colors = options.colors[row]; + var buttons = []; + for (var col = 0, colSize = colors.length; col < colSize; col++) { + var color = colors[col]; + buttons.push([ + '<button type="button" class="note-color-btn"', + 'style="background-color:', color, '" ', + 'data-event="', eventName, '" ', + 'data-value="', color, '" ', + 'title="', color, '" ', + 'data-toggle="button" tabindex="-1"></button>' + ].join('')); + } + contents.push('<div class="note-color-row">' + buttons.join('') + '</div>'); + } + $node.html(contents.join('')); + if (options.tooltip) { + $node.find('.note-color-btn').tooltip({ + container: options.container, + trigger: 'hover', + placement: 'bottom' + }); + } +}); +var dialog = renderer.create('<div class="modal" aria-hidden="false" tabindex="-1"/>', function ($node, options) { + if (options.fade) { + $node.addClass('fade'); + } + $node.html([ + '<div class="modal-dialog">', + ' <div class="modal-content">', + (options.title + ? ' <div class="modal-header">' + + ' <h4 class="modal-title">' + options.title + '</h4>' + + ' <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>' + + ' </div>' : ''), + ' <div class="modal-body">' + options.body + '</div>', + (options.footer + ? ' <div class="modal-footer">' + options.footer + '</div>' : ''), + ' </div>', + '</div>' + ].join('')); +}); +var popover = renderer.create([ + '<div class="note-popover popover in">', + ' <div class="arrow"/>', + ' <div class="popover-content note-children-container"/>', + '</div>' +].join(''), function ($node, options) { + var direction = typeof options.direction !== 'undefined' ? options.direction : 'bottom'; + $node.addClass(direction); + if (options.hideArrow) { + $node.find('.arrow').hide(); + } +}); +var checkbox = renderer.create('<label class="custom-control custom-checkbox"></label>', function ($node, options) { + if (options.id) { + $node.attr('for', options.id); + } + $node.html([ + ' <input type="checkbox" class="custom-control-input"' + (options.id ? ' id="' + options.id + '"' : ''), + (options.checked ? ' checked' : '') + '/>', + ' <span class="custom-control-indicator"></span>', + ' <span class="custom-control-description">' + (options.text ? options.text : '') + '</span>', + '</label>' + ].join('')); +}); +var icon = function (iconClassName, tagName) { + tagName = tagName || 'i'; + return '<' + tagName + ' class="' + iconClassName + '"/>'; +}; +var ui = { + editor: editor, + toolbar: toolbar, + editingArea: editingArea, + codable: codable, + editable: editable, + statusbar: statusbar, + airEditor: airEditor, + airEditable: airEditable, + buttonGroup: buttonGroup, + dropdown: dropdown, + dropdownButtonContents: dropdownButtonContents, + dropdownCheck: dropdownCheck, + palette: palette, + dialog: dialog, + popover: popover, + icon: icon, + checkbox: checkbox, + options: {}, + button: function ($node, options) { + return renderer.create('<button type="button" class="note-btn btn btn-light btn-sm" tabindex="-1">', function ($node, options) { + if (options && options.tooltip) { + $node.attr({ + title: options.tooltip + }).tooltip({ + container: options.container, + trigger: 'hover', + placement: 'bottom' + }); + } + })($node, options); + }, + toggleBtn: function ($btn, isEnable) { + $btn.toggleClass('disabled', !isEnable); + $btn.attr('disabled', !isEnable); + }, + toggleBtnActive: function ($btn, isActive) { + $btn.toggleClass('active', isActive); + }, + onDialogShown: function ($dialog, handler) { + $dialog.one('shown.bs.modal', handler); + }, + onDialogHidden: function ($dialog, handler) { + $dialog.one('hidden.bs.modal', handler); + }, + showDialog: function ($dialog) { + $dialog.modal('show'); + }, + hideDialog: function ($dialog) { + $dialog.modal('hide'); + }, + createLayout: function ($note, options) { + var $editor = (options.airMode ? ui.airEditor([ + ui.editingArea([ + ui.airEditable() + ]) + ]) : ui.editor([ + ui.toolbar(), + ui.editingArea([ + ui.codable(), + ui.editable() + ]), + ui.statusbar() + ])).render(); + $editor.insertAfter($note); + return { + note: $note, + editor: $editor, + toolbar: $editor.find('.note-toolbar'), + editingArea: $editor.find('.note-editing-area'), + editable: $editor.find('.note-editable'), + codable: $editor.find('.note-codable'), + statusbar: $editor.find('.note-statusbar') + }; + }, + removeLayout: function ($note, layoutInfo) { + $note.html(layoutInfo.editable.html()); + layoutInfo.editor.remove(); + $note.show(); + } +}; + +/** + * @class core.func + * + * func utils (for high-order func's arg) + * + * @singleton + * @alternateClassName func + */ +function eq(itemA) { + return function (itemB) { + return itemA === itemB; + }; +} +function eq2(itemA, itemB) { + return itemA === itemB; +} +function peq2(propName) { + return function (itemA, itemB) { + return itemA[propName] === itemB[propName]; + }; +} +function ok() { + return true; +} +function fail() { + return false; +} +function not(f) { + return function () { + return !f.apply(f, arguments); + }; +} +function and(fA, fB) { + return function (item) { + return fA(item) && fB(item); + }; +} +function self(a) { + return a; +} +function invoke(obj, method) { + return function () { + return obj[method].apply(obj, arguments); + }; +} +var idCounter = 0; +/** + * generate a globally-unique id + * + * @param {String} [prefix] + */ +function uniqueId(prefix) { + var id = ++idCounter + ''; + return prefix ? prefix + id : id; +} +/** + * returns bnd (bounds) from rect + * + * - IE Compatibility Issue: http://goo.gl/sRLOAo + * - Scroll Issue: http://goo.gl/sNjUc + * + * @param {Rect} rect + * @return {Object} bounds + * @return {Number} bounds.top + * @return {Number} bounds.left + * @return {Number} bounds.width + * @return {Number} bounds.height + */ +function rect2bnd(rect) { + var $document = $(document); + return { + top: rect.top + $document.scrollTop(), + left: rect.left + $document.scrollLeft(), + width: rect.right - rect.left, + height: rect.bottom - rect.top + }; +} +/** + * returns a copy of the object where the keys have become the values and the values the keys. + * @param {Object} obj + * @return {Object} + */ +function invertObject(obj) { + var inverted = {}; + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + inverted[obj[key]] = key; + } + } + return inverted; +} +/** + * @param {String} namespace + * @param {String} [prefix] + * @return {String} + */ +function namespaceToCamel(namespace, prefix) { + prefix = prefix || ''; + return prefix + namespace.split('.').map(function (name) { + return name.substring(0, 1).toUpperCase() + name.substring(1); + }).join(''); +} +/** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. If `immediate` is passed, trigger the function on the + * leading edge, instead of the trailing. + * @param {Function} func + * @param {Number} wait + * @param {Boolean} immediate + * @return {Function} + */ +function debounce(func, wait, immediate) { + var _this = this; + var timeout; + return function () { + var context = _this; + var args = arguments; + var later = function () { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + func.apply(context, args); + } + }; +} +var func = { + eq: eq, + eq2: eq2, + peq2: peq2, + ok: ok, + fail: fail, + self: self, + not: not, + and: and, + invoke: invoke, + uniqueId: uniqueId, + rect2bnd: rect2bnd, + invertObject: invertObject, + namespaceToCamel: namespaceToCamel, + debounce: debounce +}; + +/** + * returns the first item of an array. + * + * @param {Array} array + */ +function head(array) { + return array[0]; +} +/** + * returns the last item of an array. + * + * @param {Array} array + */ +function last(array) { + return array[array.length - 1]; +} +/** + * returns everything but the last entry of the array. + * + * @param {Array} array + */ +function initial(array) { + return array.slice(0, array.length - 1); +} +/** + * returns the rest of the items in an array. + * + * @param {Array} array + */ +function tail(array) { + return array.slice(1); +} +/** + * returns item of array + */ +function find(array, pred) { + for (var idx = 0, len = array.length; idx < len; idx++) { + var item = array[idx]; + if (pred(item)) { + return item; + } + } +} +/** + * returns true if all of the values in the array pass the predicate truth test. + */ +function all(array, pred) { + for (var idx = 0, len = array.length; idx < len; idx++) { + if (!pred(array[idx])) { + return false; + } + } + return true; +} +/** + * returns index of item + */ +function indexOf(array, item) { + return $$1.inArray(item, array); +} +/** + * returns true if the value is present in the list. + */ +function contains(array, item) { + return indexOf(array, item) !== -1; +} +/** + * get sum from a list + * + * @param {Array} array - array + * @param {Function} fn - iterator + */ +function sum(array, fn) { + fn = fn || func.self; + return array.reduce(function (memo, v) { + return memo + fn(v); + }, 0); +} +/** + * returns a copy of the collection with array type. + * @param {Collection} collection - collection eg) node.childNodes, ... + */ +function from(collection) { + var result = []; + var length = collection.length; + var idx = -1; + while (++idx < length) { + result[idx] = collection[idx]; + } + return result; +} +/** + * returns whether list is empty or not + */ +function isEmpty$1(array) { + return !array || !array.length; +} +/** + * cluster elements by predicate function. + * + * @param {Array} array - array + * @param {Function} fn - predicate function for cluster rule + * @param {Array[]} + */ +function clusterBy(array, fn) { + if (!array.length) { + return []; + } + var aTail = tail(array); + return aTail.reduce(function (memo, v) { + var aLast = last(memo); + if (fn(last(aLast), v)) { + aLast[aLast.length] = v; + } + else { + memo[memo.length] = [v]; + } + return memo; + }, [[head(array)]]); +} +/** + * returns a copy of the array with all false values removed + * + * @param {Array} array - array + * @param {Function} fn - predicate function for cluster rule + */ +function compact(array) { + var aResult = []; + for (var idx = 0, len = array.length; idx < len; idx++) { + if (array[idx]) { + aResult.push(array[idx]); + } + } + return aResult; +} +/** + * produces a duplicate-free version of the array + * + * @param {Array} array + */ +function unique(array) { + var results = []; + for (var idx = 0, len = array.length; idx < len; idx++) { + if (!contains(results, array[idx])) { + results.push(array[idx]); + } + } + return results; +} +/** + * returns next item. + * @param {Array} array + */ +function next(array, item) { + var idx = indexOf(array, item); + if (idx === -1) { + return null; + } + return array[idx + 1]; +} +/** + * returns prev item. + * @param {Array} array + */ +function prev(array, item) { + var idx = indexOf(array, item); + if (idx === -1) { + return null; + } + return array[idx - 1]; +} +/** + * @class core.list + * + * list utils + * + * @singleton + * @alternateClassName list + */ +var lists = { + head: head, + last: last, + initial: initial, + tail: tail, + prev: prev, + next: next, + find: find, + contains: contains, + all: all, + sum: sum, + from: from, + isEmpty: isEmpty$1, + clusterBy: clusterBy, + compact: compact, + unique: unique +}; + +var isSupportAmd = typeof define === 'function' && define.amd; // eslint-disable-line +/** + * returns whether font is installed or not. + * + * @param {String} fontName + * @return {Boolean} + */ +function isFontInstalled(fontName) { + var testFontName = fontName === 'Comic Sans MS' ? 'Courier New' : 'Comic Sans MS'; + var $tester = $$1('<div>').css({ + position: 'absolute', + left: '-9999px', + top: '-9999px', + fontSize: '200px' + }).text('mmmmmmmmmwwwwwww').appendTo(document.body); + var originalWidth = $tester.css('fontFamily', testFontName).width(); + var width = $tester.css('fontFamily', fontName + ',' + testFontName).width(); + $tester.remove(); + return originalWidth !== width; +} +var userAgent = navigator.userAgent; +var isMSIE = /MSIE|Trident/i.test(userAgent); +var browserVersion; +if (isMSIE) { + var matches = /MSIE (\d+[.]\d+)/.exec(userAgent); + if (matches) { + browserVersion = parseFloat(matches[1]); + } + matches = /Trident\/.*rv:([0-9]{1,}[.0-9]{0,})/.exec(userAgent); + if (matches) { + browserVersion = parseFloat(matches[1]); + } +} +var isEdge = /Edge\/\d+/.test(userAgent); +var hasCodeMirror = !!window.CodeMirror; +if (!hasCodeMirror && isSupportAmd) { + // Webpack + if (typeof __webpack_require__ === 'function') { + try { + // If CodeMirror can't be resolved, `require.resolve` will throw an + // exception and `hasCodeMirror` won't be set to `true`. + require.resolve('codemirror'); + hasCodeMirror = true; + } + catch (e) { + // do nothing + } + } + else if (typeof require !== 'undefined') { + // Browserify + if (typeof require.resolve !== 'undefined') { + try { + // If CodeMirror can't be resolved, `require.resolve` will throw an + // exception and `hasCodeMirror` won't be set to `true`. + require.resolve('codemirror'); + hasCodeMirror = true; + } + catch (e) { + // do nothing + } + // Almond/Require + } + else if (typeof require.specified !== 'undefined') { + hasCodeMirror = require.specified('codemirror'); + } + } +} +var isSupportTouch = (('ontouchstart' in window) || + (navigator.MaxTouchPoints > 0) || + (navigator.msMaxTouchPoints > 0)); +// [workaround] IE doesn't have input events for contentEditable +// - see: https://goo.gl/4bfIvA +var inputEventName = (isMSIE || isEdge) ? 'DOMCharacterDataModified DOMSubtreeModified DOMNodeInserted' : 'input'; +/** + * @class core.env + * + * Object which check platform and agent + * + * @singleton + * @alternateClassName env + */ +var env = { + isMac: navigator.appVersion.indexOf('Mac') > -1, + isMSIE: isMSIE, + isEdge: isEdge, + isFF: !isEdge && /firefox/i.test(userAgent), + isPhantom: /PhantomJS/i.test(userAgent), + isWebkit: !isEdge && /webkit/i.test(userAgent), + isChrome: !isEdge && /chrome/i.test(userAgent), + isSafari: !isEdge && /safari/i.test(userAgent), + browserVersion: browserVersion, + jqueryVersion: parseFloat($$1.fn.jquery), + isSupportAmd: isSupportAmd, + isSupportTouch: isSupportTouch, + hasCodeMirror: hasCodeMirror, + isFontInstalled: isFontInstalled, + isW3CRangeSupport: !!document.createRange, + inputEventName: inputEventName +}; + +var NBSP_CHAR = String.fromCharCode(160); +var ZERO_WIDTH_NBSP_CHAR = '\ufeff'; +/** + * @method isEditable + * + * returns whether node is `note-editable` or not. + * + * @param {Node} node + * @return {Boolean} + */ +function isEditable(node) { + return node && $$1(node).hasClass('note-editable'); +} +/** + * @method isControlSizing + * + * returns whether node is `note-control-sizing` or not. + * + * @param {Node} node + * @return {Boolean} + */ +function isControlSizing(node) { + return node && $$1(node).hasClass('note-control-sizing'); +} +/** + * @method makePredByNodeName + * + * returns predicate which judge whether nodeName is same + * + * @param {String} nodeName + * @return {Function} + */ +function makePredByNodeName(nodeName) { + nodeName = nodeName.toUpperCase(); + return function (node) { + return node && node.nodeName.toUpperCase() === nodeName; + }; +} +/** + * @method isText + * + * + * + * @param {Node} node + * @return {Boolean} true if node's type is text(3) + */ +function isText(node) { + return node && node.nodeType === 3; +} +/** + * @method isElement + * + * + * + * @param {Node} node + * @return {Boolean} true if node's type is element(1) + */ +function isElement(node) { + return node && node.nodeType === 1; +} +/** + * ex) br, col, embed, hr, img, input, ... + * @see http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements + */ +function isVoid(node) { + return node && /^BR|^IMG|^HR|^IFRAME|^BUTTON|^INPUT/.test(node.nodeName.toUpperCase()); +} +function isPara(node) { + if (isEditable(node)) { + return false; + } + // Chrome(v31.0), FF(v25.0.1) use DIV for paragraph + return node && /^DIV|^P|^LI|^H[1-7]/.test(node.nodeName.toUpperCase()); +} +function isHeading(node) { + return node && /^H[1-7]/.test(node.nodeName.toUpperCase()); +} +var isPre = makePredByNodeName('PRE'); +var isLi = makePredByNodeName('LI'); +function isPurePara(node) { + return isPara(node) && !isLi(node); +} +var isTable = makePredByNodeName('TABLE'); +var isData = makePredByNodeName('DATA'); +function isInline(node) { + return !isBodyContainer(node) && + !isList(node) && + !isHr(node) && + !isPara(node) && + !isTable(node) && + !isBlockquote(node) && + !isData(node); +} +function isList(node) { + return node && /^UL|^OL/.test(node.nodeName.toUpperCase()); +} +var isHr = makePredByNodeName('HR'); +function isCell(node) { + return node && /^TD|^TH/.test(node.nodeName.toUpperCase()); +} +var isBlockquote = makePredByNodeName('BLOCKQUOTE'); +function isBodyContainer(node) { + return isCell(node) || isBlockquote(node) || isEditable(node); +} +var isAnchor = makePredByNodeName('A'); +function isParaInline(node) { + return isInline(node) && !!ancestor(node, isPara); +} +function isBodyInline(node) { + return isInline(node) && !ancestor(node, isPara); +} +var isBody = makePredByNodeName('BODY'); +/** + * returns whether nodeB is closest sibling of nodeA + * + * @param {Node} nodeA + * @param {Node} nodeB + * @return {Boolean} + */ +function isClosestSibling(nodeA, nodeB) { + return nodeA.nextSibling === nodeB || + nodeA.previousSibling === nodeB; +} +/** + * returns array of closest siblings with node + * + * @param {Node} node + * @param {function} [pred] - predicate function + * @return {Node[]} + */ +function withClosestSiblings(node, pred) { + pred = pred || func.ok; + var siblings = []; + if (node.previousSibling && pred(node.previousSibling)) { + siblings.push(node.previousSibling); + } + siblings.push(node); + if (node.nextSibling && pred(node.nextSibling)) { + siblings.push(node.nextSibling); + } + return siblings; +} +/** + * blank HTML for cursor position + * - [workaround] old IE only works with + * - [workaround] IE11 and other browser works with bogus br + */ +var blankHTML = env.isMSIE && env.browserVersion < 11 ? ' ' : '<br>'; +/** + * @method nodeLength + * + * returns #text's text size or element's childNodes size + * + * @param {Node} node + */ +function nodeLength(node) { + if (isText(node)) { + return node.nodeValue.length; + } + if (node) { + return node.childNodes.length; + } + return 0; +} +/** + * returns whether node is empty or not. + * + * @param {Node} node + * @return {Boolean} + */ +function isEmpty(node) { + var len = nodeLength(node); + if (len === 0) { + return true; + } + else if (!isText(node) && len === 1 && node.innerHTML === blankHTML) { + // ex) <p><br></p>, <span><br></span> + return true; + } + else if (lists.all(node.childNodes, isText) && node.innerHTML === '') { + // ex) <p></p>, <span></span> + return true; + } + return false; +} +/** + * padding blankHTML if node is empty (for cursor position) + */ +function paddingBlankHTML(node) { + if (!isVoid(node) && !nodeLength(node)) { + node.innerHTML = blankHTML; + } +} +/** + * find nearest ancestor predicate hit + * + * @param {Node} node + * @param {Function} pred - predicate function + */ +function ancestor(node, pred) { + while (node) { + if (pred(node)) { + return node; + } + if (isEditable(node)) { + break; + } + node = node.parentNode; + } + return null; +} +/** + * find nearest ancestor only single child blood line and predicate hit + * + * @param {Node} node + * @param {Function} pred - predicate function + */ +function singleChildAncestor(node, pred) { + node = node.parentNode; + while (node) { + if (nodeLength(node) !== 1) { + break; + } + if (pred(node)) { + return node; + } + if (isEditable(node)) { + break; + } + node = node.parentNode; + } + return null; +} +/** + * returns new array of ancestor nodes (until predicate hit). + * + * @param {Node} node + * @param {Function} [optional] pred - predicate function + */ +function listAncestor(node, pred) { + pred = pred || func.fail; + var ancestors = []; + ancestor(node, function (el) { + if (!isEditable(el)) { + ancestors.push(el); + } + return pred(el); + }); + return ancestors; +} +/** + * find farthest ancestor predicate hit + */ +function lastAncestor(node, pred) { + var ancestors = listAncestor(node); + return lists.last(ancestors.filter(pred)); +} +/** + * returns common ancestor node between two nodes. + * + * @param {Node} nodeA + * @param {Node} nodeB + */ +function commonAncestor(nodeA, nodeB) { + var ancestors = listAncestor(nodeA); + for (var n = nodeB; n; n = n.parentNode) { + if ($$1.inArray(n, ancestors) > -1) { + return n; + } + } + return null; // difference document area +} +/** + * listing all previous siblings (until predicate hit). + * + * @param {Node} node + * @param {Function} [optional] pred - predicate function + */ +function listPrev(node, pred) { + pred = pred || func.fail; + var nodes = []; + while (node) { + if (pred(node)) { + break; + } + nodes.push(node); + node = node.previousSibling; + } + return nodes; +} +/** + * listing next siblings (until predicate hit). + * + * @param {Node} node + * @param {Function} [pred] - predicate function + */ +function listNext(node, pred) { + pred = pred || func.fail; + var nodes = []; + while (node) { + if (pred(node)) { + break; + } + nodes.push(node); + node = node.nextSibling; + } + return nodes; +} +/** + * listing descendant nodes + * + * @param {Node} node + * @param {Function} [pred] - predicate function + */ +function listDescendant(node, pred) { + var descendants = []; + pred = pred || func.ok; + // start DFS(depth first search) with node + (function fnWalk(current) { + if (node !== current && pred(current)) { + descendants.push(current); + } + for (var idx = 0, len = current.childNodes.length; idx < len; idx++) { + fnWalk(current.childNodes[idx]); + } + })(node); + return descendants; +} +/** + * wrap node with new tag. + * + * @param {Node} node + * @param {Node} tagName of wrapper + * @return {Node} - wrapper + */ +function wrap(node, wrapperName) { + var parent = node.parentNode; + var wrapper = $$1('<' + wrapperName + '>')[0]; + parent.insertBefore(wrapper, node); + wrapper.appendChild(node); + return wrapper; +} +/** + * insert node after preceding + * + * @param {Node} node + * @param {Node} preceding - predicate function + */ +function insertAfter(node, preceding) { + var next = preceding.nextSibling; + var parent = preceding.parentNode; + if (next) { + parent.insertBefore(node, next); + } + else { + parent.appendChild(node); + } + return node; +} +/** + * append elements. + * + * @param {Node} node + * @param {Collection} aChild + */ +function appendChildNodes(node, aChild) { + $$1.each(aChild, function (idx, child) { + node.appendChild(child); + }); + return node; +} +/** + * returns whether boundaryPoint is left edge or not. + * + * @param {BoundaryPoint} point + * @return {Boolean} + */ +function isLeftEdgePoint(point) { + return point.offset === 0; +} +/** + * returns whether boundaryPoint is right edge or not. + * + * @param {BoundaryPoint} point + * @return {Boolean} + */ +function isRightEdgePoint(point) { + return point.offset === nodeLength(point.node); +} +/** + * returns whether boundaryPoint is edge or not. + * + * @param {BoundaryPoint} point + * @return {Boolean} + */ +function isEdgePoint(point) { + return isLeftEdgePoint(point) || isRightEdgePoint(point); +} +/** + * returns whether node is left edge of ancestor or not. + * + * @param {Node} node + * @param {Node} ancestor + * @return {Boolean} + */ +function isLeftEdgeOf(node, ancestor) { + while (node && node !== ancestor) { + if (position(node) !== 0) { + return false; + } + node = node.parentNode; + } + return true; +} +/** + * returns whether node is right edge of ancestor or not. + * + * @param {Node} node + * @param {Node} ancestor + * @return {Boolean} + */ +function isRightEdgeOf(node, ancestor) { + if (!ancestor) { + return false; + } + while (node && node !== ancestor) { + if (position(node) !== nodeLength(node.parentNode) - 1) { + return false; + } + node = node.parentNode; + } + return true; +} +/** + * returns whether point is left edge of ancestor or not. + * @param {BoundaryPoint} point + * @param {Node} ancestor + * @return {Boolean} + */ +function isLeftEdgePointOf(point, ancestor) { + return isLeftEdgePoint(point) && isLeftEdgeOf(point.node, ancestor); +} +/** + * returns whether point is right edge of ancestor or not. + * @param {BoundaryPoint} point + * @param {Node} ancestor + * @return {Boolean} + */ +function isRightEdgePointOf(point, ancestor) { + return isRightEdgePoint(point) && isRightEdgeOf(point.node, ancestor); +} +/** + * returns offset from parent. + * + * @param {Node} node + */ +function position(node) { + var offset = 0; + while ((node = node.previousSibling)) { + offset += 1; + } + return offset; +} +function hasChildren(node) { + return !!(node && node.childNodes && node.childNodes.length); +} +/** + * returns previous boundaryPoint + * + * @param {BoundaryPoint} point + * @param {Boolean} isSkipInnerOffset + * @return {BoundaryPoint} + */ +function prevPoint(point, isSkipInnerOffset) { + var node; + var offset; + if (point.offset === 0) { + if (isEditable(point.node)) { + return null; + } + node = point.node.parentNode; + offset = position(point.node); + } + else if (hasChildren(point.node)) { + node = point.node.childNodes[point.offset - 1]; + offset = nodeLength(node); + } + else { + node = point.node; + offset = isSkipInnerOffset ? 0 : point.offset - 1; + } + return { + node: node, + offset: offset + }; +} +/** + * returns next boundaryPoint + * + * @param {BoundaryPoint} point + * @param {Boolean} isSkipInnerOffset + * @return {BoundaryPoint} + */ +function nextPoint(point, isSkipInnerOffset) { + var node, offset; + if (nodeLength(point.node) === point.offset) { + if (isEditable(point.node)) { + return null; + } + node = point.node.parentNode; + offset = position(point.node) + 1; + } + else if (hasChildren(point.node)) { + node = point.node.childNodes[point.offset]; + offset = 0; + } + else { + node = point.node; + offset = isSkipInnerOffset ? nodeLength(point.node) : point.offset + 1; + } + return { + node: node, + offset: offset + }; +} +/** + * returns whether pointA and pointB is same or not. + * + * @param {BoundaryPoint} pointA + * @param {BoundaryPoint} pointB + * @return {Boolean} + */ +function isSamePoint(pointA, pointB) { + return pointA.node === pointB.node && pointA.offset === pointB.offset; +} +/** + * returns whether point is visible (can set cursor) or not. + * + * @param {BoundaryPoint} point + * @return {Boolean} + */ +function isVisiblePoint(point) { + if (isText(point.node) || !hasChildren(point.node) || isEmpty(point.node)) { + return true; + } + var leftNode = point.node.childNodes[point.offset - 1]; + var rightNode = point.node.childNodes[point.offset]; + if ((!leftNode || isVoid(leftNode)) && (!rightNode || isVoid(rightNode))) { + return true; + } + return false; +} +/** + * @method prevPointUtil + * + * @param {BoundaryPoint} point + * @param {Function} pred + * @return {BoundaryPoint} + */ +function prevPointUntil(point, pred) { + while (point) { + if (pred(point)) { + return point; + } + point = prevPoint(point); + } + return null; +} +/** + * @method nextPointUntil + * + * @param {BoundaryPoint} point + * @param {Function} pred + * @return {BoundaryPoint} + */ +function nextPointUntil(point, pred) { + while (point) { + if (pred(point)) { + return point; + } + point = nextPoint(point); + } + return null; +} +/** + * returns whether point has character or not. + * + * @param {Point} point + * @return {Boolean} + */ +function isCharPoint(point) { + if (!isText(point.node)) { + return false; + } + var ch = point.node.nodeValue.charAt(point.offset - 1); + return ch && (ch !== ' ' && ch !== NBSP_CHAR); +} +/** + * @method walkPoint + * + * @param {BoundaryPoint} startPoint + * @param {BoundaryPoint} endPoint + * @param {Function} handler + * @param {Boolean} isSkipInnerOffset + */ +function walkPoint(startPoint, endPoint, handler, isSkipInnerOffset) { + var point = startPoint; + while (point) { + handler(point); + if (isSamePoint(point, endPoint)) { + break; + } + var isSkipOffset = isSkipInnerOffset && + startPoint.node !== point.node && + endPoint.node !== point.node; + point = nextPoint(point, isSkipOffset); + } +} +/** + * @method makeOffsetPath + * + * return offsetPath(array of offset) from ancestor + * + * @param {Node} ancestor - ancestor node + * @param {Node} node + */ +function makeOffsetPath(ancestor, node) { + var ancestors = listAncestor(node, func.eq(ancestor)); + return ancestors.map(position).reverse(); +} +/** + * @method fromOffsetPath + * + * return element from offsetPath(array of offset) + * + * @param {Node} ancestor - ancestor node + * @param {array} offsets - offsetPath + */ +function fromOffsetPath(ancestor, offsets) { + var current = ancestor; + for (var i = 0, len = offsets.length; i < len; i++) { + if (current.childNodes.length <= offsets[i]) { + current = current.childNodes[current.childNodes.length - 1]; + } + else { + current = current.childNodes[offsets[i]]; + } + } + return current; +} +/** + * @method splitNode + * + * split element or #text + * + * @param {BoundaryPoint} point + * @param {Object} [options] + * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false + * @param {Boolean} [options.isNotSplitEdgePoint] - default: false + * @return {Node} right node of boundaryPoint + */ +function splitNode(point, options) { + var isSkipPaddingBlankHTML = options && options.isSkipPaddingBlankHTML; + var isNotSplitEdgePoint = options && options.isNotSplitEdgePoint; + // edge case + if (isEdgePoint(point) && (isText(point.node) || isNotSplitEdgePoint)) { + if (isLeftEdgePoint(point)) { + return point.node; + } + else if (isRightEdgePoint(point)) { + return point.node.nextSibling; + } + } + // split #text + if (isText(point.node)) { + return point.node.splitText(point.offset); + } + else { + var childNode = point.node.childNodes[point.offset]; + var clone = insertAfter(point.node.cloneNode(false), point.node); + appendChildNodes(clone, listNext(childNode)); + if (!isSkipPaddingBlankHTML) { + paddingBlankHTML(point.node); + paddingBlankHTML(clone); + } + return clone; + } +} +/** + * @method splitTree + * + * split tree by point + * + * @param {Node} root - split root + * @param {BoundaryPoint} point + * @param {Object} [options] + * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false + * @param {Boolean} [options.isNotSplitEdgePoint] - default: false + * @return {Node} right node of boundaryPoint + */ +function splitTree(root, point, options) { + // ex) [#text, <span>, <p>] + var ancestors = listAncestor(point.node, func.eq(root)); + if (!ancestors.length) { + return null; + } + else if (ancestors.length === 1) { + return splitNode(point, options); + } + return ancestors.reduce(function (node, parent) { + if (node === point.node) { + node = splitNode(point, options); + } + return splitNode({ + node: parent, + offset: node ? position(node) : nodeLength(parent) + }, options); + }); +} +/** + * split point + * + * @param {Point} point + * @param {Boolean} isInline + * @return {Object} + */ +function splitPoint(point, isInline) { + // find splitRoot, container + // - inline: splitRoot is a child of paragraph + // - block: splitRoot is a child of bodyContainer + var pred = isInline ? isPara : isBodyContainer; + var ancestors = listAncestor(point.node, pred); + var topAncestor = lists.last(ancestors) || point.node; + var splitRoot, container; + if (pred(topAncestor)) { + splitRoot = ancestors[ancestors.length - 2]; + container = topAncestor; + } + else { + splitRoot = topAncestor; + container = splitRoot.parentNode; + } + // if splitRoot is exists, split with splitTree + var pivot = splitRoot && splitTree(splitRoot, point, { + isSkipPaddingBlankHTML: isInline, + isNotSplitEdgePoint: isInline + }); + // if container is point.node, find pivot with point.offset + if (!pivot && container === point.node) { + pivot = point.node.childNodes[point.offset]; + } + return { + rightNode: pivot, + container: container + }; +} +function create(nodeName) { + return document.createElement(nodeName); +} +function createText(text) { + return document.createTextNode(text); +} +/** + * @method remove + * + * remove node, (isRemoveChild: remove child or not) + * + * @param {Node} node + * @param {Boolean} isRemoveChild + */ +function remove(node, isRemoveChild) { + if (!node || !node.parentNode) { + return; + } + if (node.removeNode) { + return node.removeNode(isRemoveChild); + } + var parent = node.parentNode; + if (!isRemoveChild) { + var nodes = []; + for (var i = 0, len = node.childNodes.length; i < len; i++) { + nodes.push(node.childNodes[i]); + } + for (var i = 0, len = nodes.length; i < len; i++) { + parent.insertBefore(nodes[i], node); + } + } + parent.removeChild(node); +} +/** + * @method removeWhile + * + * @param {Node} node + * @param {Function} pred + */ +function removeWhile(node, pred) { + while (node) { + if (isEditable(node) || !pred(node)) { + break; + } + var parent = node.parentNode; + remove(node); + node = parent; + } +} +/** + * @method replace + * + * replace node with provided nodeName + * + * @param {Node} node + * @param {String} nodeName + * @return {Node} - new node + */ +function replace(node, nodeName) { + if (node.nodeName.toUpperCase() === nodeName.toUpperCase()) { + return node; + } + var newNode = create(nodeName); + if (node.style.cssText) { + newNode.style.cssText = node.style.cssText; + } + appendChildNodes(newNode, lists.from(node.childNodes)); + insertAfter(newNode, node); + remove(node); + return newNode; +} +var isTextarea = makePredByNodeName('TEXTAREA'); +/** + * @param {jQuery} $node + * @param {Boolean} [stripLinebreaks] - default: false + */ +function value($node, stripLinebreaks) { + var val = isTextarea($node[0]) ? $node.val() : $node.html(); + if (stripLinebreaks) { + return val.replace(/[\n\r]/g, ''); + } + return val; +} +/** + * @method html + * + * get the HTML contents of node + * + * @param {jQuery} $node + * @param {Boolean} [isNewlineOnBlock] + */ +function html($node, isNewlineOnBlock) { + var markup = value($node); + if (isNewlineOnBlock) { + var regexTag = /<(\/?)(\b(?!!)[^>\s]*)(.*?)(\s*\/?>)/g; + markup = markup.replace(regexTag, function (match, endSlash, name) { + name = name.toUpperCase(); + var isEndOfInlineContainer = /^DIV|^TD|^TH|^P|^LI|^H[1-7]/.test(name) && + !!endSlash; + var isBlockNode = /^BLOCKQUOTE|^TABLE|^TBODY|^TR|^HR|^UL|^OL/.test(name); + return match + ((isEndOfInlineContainer || isBlockNode) ? '\n' : ''); + }); + markup = $$1.trim(markup); + } + return markup; +} +function posFromPlaceholder(placeholder) { + var $placeholder = $$1(placeholder); + var pos = $placeholder.offset(); + var height = $placeholder.outerHeight(true); // include margin + return { + left: pos.left, + top: pos.top + height + }; +} +function attachEvents($node, events) { + Object.keys(events).forEach(function (key) { + $node.on(key, events[key]); + }); +} +function detachEvents($node, events) { + Object.keys(events).forEach(function (key) { + $node.off(key, events[key]); + }); +} +/** + * @method isCustomStyleTag + * + * assert if a node contains a "note-styletag" class, + * which implies that's a custom-made style tag node + * + * @param {Node} an HTML DOM node + */ +function isCustomStyleTag(node) { + return node && !isText(node) && lists.contains(node.classList, 'note-styletag'); +} +var dom = { + /** @property {String} NBSP_CHAR */ + NBSP_CHAR: NBSP_CHAR, + /** @property {String} ZERO_WIDTH_NBSP_CHAR */ + ZERO_WIDTH_NBSP_CHAR: ZERO_WIDTH_NBSP_CHAR, + /** @property {String} blank */ + blank: blankHTML, + /** @property {String} emptyPara */ + emptyPara: "<p>" + blankHTML + "</p>", + makePredByNodeName: makePredByNodeName, + isEditable: isEditable, + isControlSizing: isControlSizing, + isText: isText, + isElement: isElement, + isVoid: isVoid, + isPara: isPara, + isPurePara: isPurePara, + isHeading: isHeading, + isInline: isInline, + isBlock: func.not(isInline), + isBodyInline: isBodyInline, + isBody: isBody, + isParaInline: isParaInline, + isPre: isPre, + isList: isList, + isTable: isTable, + isData: isData, + isCell: isCell, + isBlockquote: isBlockquote, + isBodyContainer: isBodyContainer, + isAnchor: isAnchor, + isDiv: makePredByNodeName('DIV'), + isLi: isLi, + isBR: makePredByNodeName('BR'), + isSpan: makePredByNodeName('SPAN'), + isB: makePredByNodeName('B'), + isU: makePredByNodeName('U'), + isS: makePredByNodeName('S'), + isI: makePredByNodeName('I'), + isImg: makePredByNodeName('IMG'), + isTextarea: isTextarea, + isEmpty: isEmpty, + isEmptyAnchor: func.and(isAnchor, isEmpty), + isClosestSibling: isClosestSibling, + withClosestSiblings: withClosestSiblings, + nodeLength: nodeLength, + isLeftEdgePoint: isLeftEdgePoint, + isRightEdgePoint: isRightEdgePoint, + isEdgePoint: isEdgePoint, + isLeftEdgeOf: isLeftEdgeOf, + isRightEdgeOf: isRightEdgeOf, + isLeftEdgePointOf: isLeftEdgePointOf, + isRightEdgePointOf: isRightEdgePointOf, + prevPoint: prevPoint, + nextPoint: nextPoint, + isSamePoint: isSamePoint, + isVisiblePoint: isVisiblePoint, + prevPointUntil: prevPointUntil, + nextPointUntil: nextPointUntil, + isCharPoint: isCharPoint, + walkPoint: walkPoint, + ancestor: ancestor, + singleChildAncestor: singleChildAncestor, + listAncestor: listAncestor, + lastAncestor: lastAncestor, + listNext: listNext, + listPrev: listPrev, + listDescendant: listDescendant, + commonAncestor: commonAncestor, + wrap: wrap, + insertAfter: insertAfter, + appendChildNodes: appendChildNodes, + position: position, + hasChildren: hasChildren, + makeOffsetPath: makeOffsetPath, + fromOffsetPath: fromOffsetPath, + splitTree: splitTree, + splitPoint: splitPoint, + create: create, + createText: createText, + remove: remove, + removeWhile: removeWhile, + replace: replace, + html: html, + value: value, + posFromPlaceholder: posFromPlaceholder, + attachEvents: attachEvents, + detachEvents: detachEvents, + isCustomStyleTag: isCustomStyleTag +}; + +$$1.summernote = $$1.summernote || { + lang: {} +}; +$$1.extend($$1.summernote.lang, { + 'en-US': { + font: { + bold: 'Bold', + italic: 'Italic', + underline: 'Underline', + clear: 'Remove Font Style', + height: 'Line Height', + name: 'Font Family', + strikethrough: 'Strikethrough', + subscript: 'Subscript', + superscript: 'Superscript', + size: 'Font Size' + }, + image: { + image: 'Picture', + insert: 'Insert Image', + resizeFull: 'Resize Full', + resizeHalf: 'Resize Half', + resizeQuarter: 'Resize Quarter', + floatLeft: 'Float Left', + floatRight: 'Float Right', + floatNone: 'Float None', + shapeRounded: 'Shape: Rounded', + shapeCircle: 'Shape: Circle', + shapeThumbnail: 'Shape: Thumbnail', + shapeNone: 'Shape: None', + dragImageHere: 'Drag image or text here', + dropImage: 'Drop image or Text', + selectFromFiles: 'Select from files', + maximumFileSize: 'Maximum file size', + maximumFileSizeError: 'Maximum file size exceeded.', + url: 'Image URL', + remove: 'Remove Image', + original: 'Original' + }, + video: { + video: 'Video', + videoLink: 'Video Link', + insert: 'Insert Video', + url: 'Video URL', + providers: '(YouTube, Vimeo, Vine, Instagram, DailyMotion or Youku)' + }, + link: { + link: 'Link', + insert: 'Insert Link', + unlink: 'Unlink', + edit: 'Edit', + textToDisplay: 'Text to display', + url: 'To what URL should this link go?', + openInNewWindow: 'Open in new window' + }, + table: { + table: 'Table', + addRowAbove: 'Add row above', + addRowBelow: 'Add row below', + addColLeft: 'Add column left', + addColRight: 'Add column right', + delRow: 'Delete row', + delCol: 'Delete column', + delTable: 'Delete table' + }, + hr: { + insert: 'Insert Horizontal Rule' + }, + style: { + style: 'Style', + p: 'Normal', + blockquote: 'Quote', + pre: 'Code', + h1: 'Header 1', + h2: 'Header 2', + h3: 'Header 3', + h4: 'Header 4', + h5: 'Header 5', + h6: 'Header 6' + }, + lists: { + unordered: 'Unordered list', + ordered: 'Ordered list' + }, + options: { + help: 'Help', + fullscreen: 'Full Screen', + codeview: 'Code View' + }, + paragraph: { + paragraph: 'Paragraph', + outdent: 'Outdent', + indent: 'Indent', + left: 'Align left', + center: 'Align center', + right: 'Align right', + justify: 'Justify full' + }, + color: { + recent: 'Recent Color', + more: 'More Color', + background: 'Background Color', + foreground: 'Foreground Color', + transparent: 'Transparent', + setTransparent: 'Set transparent', + reset: 'Reset', + resetToDefault: 'Reset to default' + }, + shortcut: { + shortcuts: 'Keyboard shortcuts', + close: 'Close', + textFormatting: 'Text formatting', + action: 'Action', + paragraphFormatting: 'Paragraph formatting', + documentStyle: 'Document Style', + extraKeys: 'Extra keys' + }, + help: { + 'insertParagraph': 'Insert Paragraph', + 'undo': 'Undoes the last command', + 'redo': 'Redoes the last command', + 'tab': 'Tab', + 'untab': 'Untab', + 'bold': 'Set a bold style', + 'italic': 'Set a italic style', + 'underline': 'Set a underline style', + 'strikethrough': 'Set a strikethrough style', + 'removeFormat': 'Clean a style', + 'justifyLeft': 'Set left align', + 'justifyCenter': 'Set center align', + 'justifyRight': 'Set right align', + 'justifyFull': 'Set full align', + 'insertUnorderedList': 'Toggle unordered list', + 'insertOrderedList': 'Toggle ordered list', + 'outdent': 'Outdent on current paragraph', + 'indent': 'Indent on current paragraph', + 'formatPara': 'Change current block\'s format as a paragraph(P tag)', + 'formatH1': 'Change current block\'s format as H1', + 'formatH2': 'Change current block\'s format as H2', + 'formatH3': 'Change current block\'s format as H3', + 'formatH4': 'Change current block\'s format as H4', + 'formatH5': 'Change current block\'s format as H5', + 'formatH6': 'Change current block\'s format as H6', + 'insertHorizontalRule': 'Insert horizontal rule', + 'linkDialog.show': 'Show Link Dialog' + }, + history: { + undo: 'Undo', + redo: 'Redo' + }, + specialChar: { + specialChar: 'SPECIAL CHARACTERS', + select: 'Select Special characters' + } + } +}); + +var KEY_MAP = { + 'BACKSPACE': 8, + 'TAB': 9, + 'ENTER': 13, + 'SPACE': 32, + 'DELETE': 46, + // Arrow + 'LEFT': 37, + 'UP': 38, + 'RIGHT': 39, + 'DOWN': 40, + // Number: 0-9 + 'NUM0': 48, + 'NUM1': 49, + 'NUM2': 50, + 'NUM3': 51, + 'NUM4': 52, + 'NUM5': 53, + 'NUM6': 54, + 'NUM7': 55, + 'NUM8': 56, + // Alphabet: a-z + 'B': 66, + 'E': 69, + 'I': 73, + 'J': 74, + 'K': 75, + 'L': 76, + 'R': 82, + 'S': 83, + 'U': 85, + 'V': 86, + 'Y': 89, + 'Z': 90, + 'SLASH': 191, + 'LEFTBRACKET': 219, + 'BACKSLASH': 220, + 'RIGHTBRACKET': 221 +}; +/** + * @class core.key + * + * Object for keycodes. + * + * @singleton + * @alternateClassName key + */ +var key = { + /** + * @method isEdit + * + * @param {Number} keyCode + * @return {Boolean} + */ + isEdit: function (keyCode) { + return lists.contains([ + KEY_MAP.BACKSPACE, + KEY_MAP.TAB, + KEY_MAP.ENTER, + KEY_MAP.SPACE, + KEY_MAP.DELETE + ], keyCode); + }, + /** + * @method isMove + * + * @param {Number} keyCode + * @return {Boolean} + */ + isMove: function (keyCode) { + return lists.contains([ + KEY_MAP.LEFT, + KEY_MAP.UP, + KEY_MAP.RIGHT, + KEY_MAP.DOWN + ], keyCode); + }, + /** + * @property {Object} nameFromCode + * @property {String} nameFromCode.8 "BACKSPACE" + */ + nameFromCode: func.invertObject(KEY_MAP), + code: KEY_MAP +}; + +/** + * return boundaryPoint from TextRange, inspired by Andy Na's HuskyRange.js + * + * @param {TextRange} textRange + * @param {Boolean} isStart + * @return {BoundaryPoint} + * + * @see http://msdn.microsoft.com/en-us/library/ie/ms535872(v=vs.85).aspx + */ +function textRangeToPoint(textRange, isStart) { + var container = textRange.parentElement(); + var offset; + var tester = document.body.createTextRange(); + var prevContainer; + var childNodes = lists.from(container.childNodes); + for (offset = 0; offset < childNodes.length; offset++) { + if (dom.isText(childNodes[offset])) { + continue; + } + tester.moveToElementText(childNodes[offset]); + if (tester.compareEndPoints('StartToStart', textRange) >= 0) { + break; + } + prevContainer = childNodes[offset]; + } + if (offset !== 0 && dom.isText(childNodes[offset - 1])) { + var textRangeStart = document.body.createTextRange(); + var curTextNode = null; + textRangeStart.moveToElementText(prevContainer || container); + textRangeStart.collapse(!prevContainer); + curTextNode = prevContainer ? prevContainer.nextSibling : container.firstChild; + var pointTester = textRange.duplicate(); + pointTester.setEndPoint('StartToStart', textRangeStart); + var textCount = pointTester.text.replace(/[\r\n]/g, '').length; + while (textCount > curTextNode.nodeValue.length && curTextNode.nextSibling) { + textCount -= curTextNode.nodeValue.length; + curTextNode = curTextNode.nextSibling; + } + // [workaround] enforce IE to re-reference curTextNode, hack + var dummy = curTextNode.nodeValue; // eslint-disable-line + if (isStart && curTextNode.nextSibling && dom.isText(curTextNode.nextSibling) && + textCount === curTextNode.nodeValue.length) { + textCount -= curTextNode.nodeValue.length; + curTextNode = curTextNode.nextSibling; + } + container = curTextNode; + offset = textCount; + } + return { + cont: container, + offset: offset + }; +} +/** + * return TextRange from boundary point (inspired by google closure-library) + * @param {BoundaryPoint} point + * @return {TextRange} + */ +function pointToTextRange(point) { + var textRangeInfo = function (container, offset) { + var node, isCollapseToStart; + if (dom.isText(container)) { + var prevTextNodes = dom.listPrev(container, func.not(dom.isText)); + var prevContainer = lists.last(prevTextNodes).previousSibling; + node = prevContainer || container.parentNode; + offset += lists.sum(lists.tail(prevTextNodes), dom.nodeLength); + isCollapseToStart = !prevContainer; + } + else { + node = container.childNodes[offset] || container; + if (dom.isText(node)) { + return textRangeInfo(node, 0); + } + offset = 0; + isCollapseToStart = false; + } + return { + node: node, + collapseToStart: isCollapseToStart, + offset: offset + }; + }; + var textRange = document.body.createTextRange(); + var info = textRangeInfo(point.node, point.offset); + textRange.moveToElementText(info.node); + textRange.collapse(info.collapseToStart); + textRange.moveStart('character', info.offset); + return textRange; +} +/** + * Wrapped Range + * + * @constructor + * @param {Node} sc - start container + * @param {Number} so - start offset + * @param {Node} ec - end container + * @param {Number} eo - end offset + */ +var WrappedRange = /** @class */ (function () { + function WrappedRange(sc, so, ec, eo) { + this.sc = sc; + this.so = so; + this.ec = ec; + this.eo = eo; + // isOnEditable: judge whether range is on editable or not + this.isOnEditable = this.makeIsOn(dom.isEditable); + // isOnList: judge whether range is on list node or not + this.isOnList = this.makeIsOn(dom.isList); + // isOnAnchor: judge whether range is on anchor node or not + this.isOnAnchor = this.makeIsOn(dom.isAnchor); + // isOnCell: judge whether range is on cell node or not + this.isOnCell = this.makeIsOn(dom.isCell); + // isOnData: judge whether range is on data node or not + this.isOnData = this.makeIsOn(dom.isData); + } + // nativeRange: get nativeRange from sc, so, ec, eo + WrappedRange.prototype.nativeRange = function () { + if (env.isW3CRangeSupport) { + var w3cRange = document.createRange(); + w3cRange.setStart(this.sc, this.so); + w3cRange.setEnd(this.ec, this.eo); + return w3cRange; + } + else { + var textRange = pointToTextRange({ + node: this.sc, + offset: this.so + }); + textRange.setEndPoint('EndToEnd', pointToTextRange({ + node: this.ec, + offset: this.eo + })); + return textRange; + } + }; + WrappedRange.prototype.getPoints = function () { + return { + sc: this.sc, + so: this.so, + ec: this.ec, + eo: this.eo + }; + }; + WrappedRange.prototype.getStartPoint = function () { + return { + node: this.sc, + offset: this.so + }; + }; + WrappedRange.prototype.getEndPoint = function () { + return { + node: this.ec, + offset: this.eo + }; + }; + /** + * select update visible range + */ + WrappedRange.prototype.select = function () { + var nativeRng = this.nativeRange(); + if (env.isW3CRangeSupport) { + var selection = document.getSelection(); + if (selection.rangeCount > 0) { + selection.removeAllRanges(); + } + selection.addRange(nativeRng); + } + else { + nativeRng.select(); + } + return this; + }; + /** + * Moves the scrollbar to start container(sc) of current range + * + * @return {WrappedRange} + */ + WrappedRange.prototype.scrollIntoView = function (container) { + var height = $$1(container).height(); + if (container.scrollTop + height < this.sc.offsetTop) { + container.scrollTop += Math.abs(container.scrollTop + height - this.sc.offsetTop); + } + return this; + }; + /** + * @return {WrappedRange} + */ + WrappedRange.prototype.normalize = function () { + /** + * @param {BoundaryPoint} point + * @param {Boolean} isLeftToRight + * @return {BoundaryPoint} + */ + var getVisiblePoint = function (point, isLeftToRight) { + if ((dom.isVisiblePoint(point) && !dom.isEdgePoint(point)) || + (dom.isVisiblePoint(point) && dom.isRightEdgePoint(point) && !isLeftToRight) || + (dom.isVisiblePoint(point) && dom.isLeftEdgePoint(point) && isLeftToRight) || + (dom.isVisiblePoint(point) && dom.isBlock(point.node) && dom.isEmpty(point.node))) { + return point; + } + // point on block's edge + var block = dom.ancestor(point.node, dom.isBlock); + if (((dom.isLeftEdgePointOf(point, block) || dom.isVoid(dom.prevPoint(point).node)) && !isLeftToRight) || + ((dom.isRightEdgePointOf(point, block) || dom.isVoid(dom.nextPoint(point).node)) && isLeftToRight)) { + // returns point already on visible point + if (dom.isVisiblePoint(point)) { + return point; + } + // reverse direction + isLeftToRight = !isLeftToRight; + } + var nextPoint = isLeftToRight ? dom.nextPointUntil(dom.nextPoint(point), dom.isVisiblePoint) + : dom.prevPointUntil(dom.prevPoint(point), dom.isVisiblePoint); + return nextPoint || point; + }; + var endPoint = getVisiblePoint(this.getEndPoint(), false); + var startPoint = this.isCollapsed() ? endPoint : getVisiblePoint(this.getStartPoint(), true); + return new WrappedRange(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset); + }; + /** + * returns matched nodes on range + * + * @param {Function} [pred] - predicate function + * @param {Object} [options] + * @param {Boolean} [options.includeAncestor] + * @param {Boolean} [options.fullyContains] + * @return {Node[]} + */ + WrappedRange.prototype.nodes = function (pred, options) { + pred = pred || func.ok; + var includeAncestor = options && options.includeAncestor; + var fullyContains = options && options.fullyContains; + // TODO compare points and sort + var startPoint = this.getStartPoint(); + var endPoint = this.getEndPoint(); + var nodes = []; + var leftEdgeNodes = []; + dom.walkPoint(startPoint, endPoint, function (point) { + if (dom.isEditable(point.node)) { + return; + } + var node; + if (fullyContains) { + if (dom.isLeftEdgePoint(point)) { + leftEdgeNodes.push(point.node); + } + if (dom.isRightEdgePoint(point) && lists.contains(leftEdgeNodes, point.node)) { + node = point.node; + } + } + else if (includeAncestor) { + node = dom.ancestor(point.node, pred); + } + else { + node = point.node; + } + if (node && pred(node)) { + nodes.push(node); + } + }, true); + return lists.unique(nodes); + }; + /** + * returns commonAncestor of range + * @return {Element} - commonAncestor + */ + WrappedRange.prototype.commonAncestor = function () { + return dom.commonAncestor(this.sc, this.ec); + }; + /** + * returns expanded range by pred + * + * @param {Function} pred - predicate function + * @return {WrappedRange} + */ + WrappedRange.prototype.expand = function (pred) { + var startAncestor = dom.ancestor(this.sc, pred); + var endAncestor = dom.ancestor(this.ec, pred); + if (!startAncestor && !endAncestor) { + return new WrappedRange(this.sc, this.so, this.ec, this.eo); + } + var boundaryPoints = this.getPoints(); + if (startAncestor) { + boundaryPoints.sc = startAncestor; + boundaryPoints.so = 0; + } + if (endAncestor) { + boundaryPoints.ec = endAncestor; + boundaryPoints.eo = dom.nodeLength(endAncestor); + } + return new WrappedRange(boundaryPoints.sc, boundaryPoints.so, boundaryPoints.ec, boundaryPoints.eo); + }; + /** + * @param {Boolean} isCollapseToStart + * @return {WrappedRange} + */ + WrappedRange.prototype.collapse = function (isCollapseToStart) { + if (isCollapseToStart) { + return new WrappedRange(this.sc, this.so, this.sc, this.so); + } + else { + return new WrappedRange(this.ec, this.eo, this.ec, this.eo); + } + }; + /** + * splitText on range + */ + WrappedRange.prototype.splitText = function () { + var isSameContainer = this.sc === this.ec; + var boundaryPoints = this.getPoints(); + if (dom.isText(this.ec) && !dom.isEdgePoint(this.getEndPoint())) { + this.ec.splitText(this.eo); + } + if (dom.isText(this.sc) && !dom.isEdgePoint(this.getStartPoint())) { + boundaryPoints.sc = this.sc.splitText(this.so); + boundaryPoints.so = 0; + if (isSameContainer) { + boundaryPoints.ec = boundaryPoints.sc; + boundaryPoints.eo = this.eo - this.so; + } + } + return new WrappedRange(boundaryPoints.sc, boundaryPoints.so, boundaryPoints.ec, boundaryPoints.eo); + }; + /** + * delete contents on range + * @return {WrappedRange} + */ + WrappedRange.prototype.deleteContents = function () { + if (this.isCollapsed()) { + return this; + } + var rng = this.splitText(); + var nodes = rng.nodes(null, { + fullyContains: true + }); + // find new cursor point + var point = dom.prevPointUntil(rng.getStartPoint(), function (point) { + return !lists.contains(nodes, point.node); + }); + var emptyParents = []; + $$1.each(nodes, function (idx, node) { + // find empty parents + var parent = node.parentNode; + if (point.node !== parent && dom.nodeLength(parent) === 1) { + emptyParents.push(parent); + } + dom.remove(node, false); + }); + // remove empty parents + $$1.each(emptyParents, function (idx, node) { + dom.remove(node, false); + }); + return new WrappedRange(point.node, point.offset, point.node, point.offset).normalize(); + }; + /** + * makeIsOn: return isOn(pred) function + */ + WrappedRange.prototype.makeIsOn = function (pred) { + return function () { + var ancestor = dom.ancestor(this.sc, pred); + return !!ancestor && (ancestor === dom.ancestor(this.ec, pred)); + }; + }; + /** + * @param {Function} pred + * @return {Boolean} + */ + WrappedRange.prototype.isLeftEdgeOf = function (pred) { + if (!dom.isLeftEdgePoint(this.getStartPoint())) { + return false; + } + var node = dom.ancestor(this.sc, pred); + return node && dom.isLeftEdgeOf(this.sc, node); + }; + /** + * returns whether range was collapsed or not + */ + WrappedRange.prototype.isCollapsed = function () { + return this.sc === this.ec && this.so === this.eo; + }; + /** + * wrap inline nodes which children of body with paragraph + * + * @return {WrappedRange} + */ + WrappedRange.prototype.wrapBodyInlineWithPara = function () { + if (dom.isBodyContainer(this.sc) && dom.isEmpty(this.sc)) { + this.sc.innerHTML = dom.emptyPara; + return new WrappedRange(this.sc.firstChild, 0, this.sc.firstChild, 0); + } + /** + * [workaround] firefox often create range on not visible point. so normalize here. + * - firefox: |<p>text</p>| + * - chrome: <p>|text|</p> + */ + var rng = this.normalize(); + if (dom.isParaInline(this.sc) || dom.isPara(this.sc)) { + return rng; + } + // find inline top ancestor + var topAncestor; + if (dom.isInline(rng.sc)) { + var ancestors = dom.listAncestor(rng.sc, func.not(dom.isInline)); + topAncestor = lists.last(ancestors); + if (!dom.isInline(topAncestor)) { + topAncestor = ancestors[ancestors.length - 2] || rng.sc.childNodes[rng.so]; + } + } + else { + topAncestor = rng.sc.childNodes[rng.so > 0 ? rng.so - 1 : 0]; + } + // siblings not in paragraph + var inlineSiblings = dom.listPrev(topAncestor, dom.isParaInline).reverse(); + inlineSiblings = inlineSiblings.concat(dom.listNext(topAncestor.nextSibling, dom.isParaInline)); + // wrap with paragraph + if (inlineSiblings.length) { + var para = dom.wrap(lists.head(inlineSiblings), 'p'); + dom.appendChildNodes(para, lists.tail(inlineSiblings)); + } + return this.normalize(); + }; + /** + * insert node at current cursor + * + * @param {Node} node + * @return {Node} + */ + WrappedRange.prototype.insertNode = function (node) { + var rng = this.wrapBodyInlineWithPara().deleteContents(); + var info = dom.splitPoint(rng.getStartPoint(), dom.isInline(node)); + if (info.rightNode) { + info.rightNode.parentNode.insertBefore(node, info.rightNode); + } + else { + info.container.appendChild(node); + } + return node; + }; + /** + * insert html at current cursor + */ + WrappedRange.prototype.pasteHTML = function (markup) { + var contentsContainer = $$1('<div></div>').html(markup)[0]; + var childNodes = lists.from(contentsContainer.childNodes); + var rng = this.wrapBodyInlineWithPara().deleteContents(); + return childNodes.reverse().map(function (childNode) { + return rng.insertNode(childNode); + }).reverse(); + }; + /** + * returns text in range + * + * @return {String} + */ + WrappedRange.prototype.toString = function () { + var nativeRng = this.nativeRange(); + return env.isW3CRangeSupport ? nativeRng.toString() : nativeRng.text; + }; + /** + * returns range for word before cursor + * + * @param {Boolean} [findAfter] - find after cursor, default: false + * @return {WrappedRange} + */ + WrappedRange.prototype.getWordRange = function (findAfter) { + var endPoint = this.getEndPoint(); + if (!dom.isCharPoint(endPoint)) { + return this; + } + var startPoint = dom.prevPointUntil(endPoint, function (point) { + return !dom.isCharPoint(point); + }); + if (findAfter) { + endPoint = dom.nextPointUntil(endPoint, function (point) { + return !dom.isCharPoint(point); + }); + } + return new WrappedRange(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset); + }; + /** + * create offsetPath bookmark + * + * @param {Node} editable + */ + WrappedRange.prototype.bookmark = function (editable) { + return { + s: { + path: dom.makeOffsetPath(editable, this.sc), + offset: this.so + }, + e: { + path: dom.makeOffsetPath(editable, this.ec), + offset: this.eo + } + }; + }; + /** + * create offsetPath bookmark base on paragraph + * + * @param {Node[]} paras + */ + WrappedRange.prototype.paraBookmark = function (paras) { + return { + s: { + path: lists.tail(dom.makeOffsetPath(lists.head(paras), this.sc)), + offset: this.so + }, + e: { + path: lists.tail(dom.makeOffsetPath(lists.last(paras), this.ec)), + offset: this.eo + } + }; + }; + /** + * getClientRects + * @return {Rect[]} + */ + WrappedRange.prototype.getClientRects = function () { + var nativeRng = this.nativeRange(); + return nativeRng.getClientRects(); + }; + return WrappedRange; +}()); +/** + * Data structure + * * BoundaryPoint: a point of dom tree + * * BoundaryPoints: two boundaryPoints corresponding to the start and the end of the Range + * + * See to http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html#Level-2-Range-Position + */ +var range = { + /** + * create Range Object From arguments or Browser Selection + * + * @param {Node} sc - start container + * @param {Number} so - start offset + * @param {Node} ec - end container + * @param {Number} eo - end offset + * @return {WrappedRange} + */ + create: function (sc, so, ec, eo) { + if (arguments.length === 4) { + return new WrappedRange(sc, so, ec, eo); + } + else if (arguments.length === 2) { + ec = sc; + eo = so; + return new WrappedRange(sc, so, ec, eo); + } + else { + var wrappedRange = this.createFromSelection(); + if (!wrappedRange && arguments.length === 1) { + wrappedRange = this.createFromNode(arguments[0]); + return wrappedRange.collapse(dom.emptyPara === arguments[0].innerHTML); + } + return wrappedRange; + } + }, + createFromSelection: function () { + var sc, so, ec, eo; + if (env.isW3CRangeSupport) { + var selection = document.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + else if (dom.isBody(selection.anchorNode)) { + // Firefox: returns entire body as range on initialization. + // We won't never need it. + return null; + } + var nativeRng = selection.getRangeAt(0); + sc = nativeRng.startContainer; + so = nativeRng.startOffset; + ec = nativeRng.endContainer; + eo = nativeRng.endOffset; + } + else { + var textRange = document.selection.createRange(); + var textRangeEnd = textRange.duplicate(); + textRangeEnd.collapse(false); + var textRangeStart = textRange; + textRangeStart.collapse(true); + var startPoint = textRangeToPoint(textRangeStart, true); + var endPoint = textRangeToPoint(textRangeEnd, false); + // same visible point case: range was collapsed. + if (dom.isText(startPoint.node) && dom.isLeftEdgePoint(startPoint) && + dom.isTextNode(endPoint.node) && dom.isRightEdgePoint(endPoint) && + endPoint.node.nextSibling === startPoint.node) { + startPoint = endPoint; + } + sc = startPoint.cont; + so = startPoint.offset; + ec = endPoint.cont; + eo = endPoint.offset; + } + return new WrappedRange(sc, so, ec, eo); + }, + /** + * @method + * + * create WrappedRange from node + * + * @param {Node} node + * @return {WrappedRange} + */ + createFromNode: function (node) { + var sc = node; + var so = 0; + var ec = node; + var eo = dom.nodeLength(ec); + // browsers can't target a picture or void node + if (dom.isVoid(sc)) { + so = dom.listPrev(sc).length - 1; + sc = sc.parentNode; + } + if (dom.isBR(ec)) { + eo = dom.listPrev(ec).length - 1; + ec = ec.parentNode; + } + else if (dom.isVoid(ec)) { + eo = dom.listPrev(ec).length; + ec = ec.parentNode; + } + return this.create(sc, so, ec, eo); + }, + /** + * create WrappedRange from node after position + * + * @param {Node} node + * @return {WrappedRange} + */ + createFromNodeBefore: function (node) { + return this.createFromNode(node).collapse(true); + }, + /** + * create WrappedRange from node after position + * + * @param {Node} node + * @return {WrappedRange} + */ + createFromNodeAfter: function (node) { + return this.createFromNode(node).collapse(); + }, + /** + * @method + * + * create WrappedRange from bookmark + * + * @param {Node} editable + * @param {Object} bookmark + * @return {WrappedRange} + */ + createFromBookmark: function (editable, bookmark) { + var sc = dom.fromOffsetPath(editable, bookmark.s.path); + var so = bookmark.s.offset; + var ec = dom.fromOffsetPath(editable, bookmark.e.path); + var eo = bookmark.e.offset; + return new WrappedRange(sc, so, ec, eo); + }, + /** + * @method + * + * create WrappedRange from paraBookmark + * + * @param {Object} bookmark + * @param {Node[]} paras + * @return {WrappedRange} + */ + createFromParaBookmark: function (bookmark, paras) { + var so = bookmark.s.offset; + var eo = bookmark.e.offset; + var sc = dom.fromOffsetPath(lists.head(paras), bookmark.s.path); + var ec = dom.fromOffsetPath(lists.last(paras), bookmark.e.path); + return new WrappedRange(sc, so, ec, eo); + } +}; + +/** + * @method readFileAsDataURL + * + * read contents of file as representing URL + * + * @param {File} file + * @return {Promise} - then: dataUrl + */ +function readFileAsDataURL(file) { + return $$1.Deferred(function (deferred) { + $$1.extend(new FileReader(), { + onload: function (e) { + var dataURL = e.target.result; + deferred.resolve(dataURL); + }, + onerror: function (err) { + deferred.reject(err); + } + }).readAsDataURL(file); + }).promise(); +} +/** + * @method createImage + * + * create `<image>` from url string + * + * @param {String} url + * @return {Promise} - then: $image + */ +function createImage(url) { + return $$1.Deferred(function (deferred) { + var $img = $$1('<img>'); + $img.one('load', function () { + $img.off('error abort'); + deferred.resolve($img); + }).one('error abort', function () { + $img.off('load').detach(); + deferred.reject($img); + }).css({ + display: 'none' + }).appendTo(document.body).attr('src', url); + }).promise(); +} + +var History = /** @class */ (function () { + function History($editable) { + this.stack = []; + this.stackOffset = -1; + this.$editable = $editable; + this.editable = $editable[0]; + } + History.prototype.makeSnapshot = function () { + var rng = range.create(this.editable); + var emptyBookmark = { s: { path: [], offset: 0 }, e: { path: [], offset: 0 } }; + return { + contents: this.$editable.html(), + bookmark: (rng ? rng.bookmark(this.editable) : emptyBookmark) + }; + }; + History.prototype.applySnapshot = function (snapshot) { + if (snapshot.contents !== null) { + this.$editable.html(snapshot.contents); + } + if (snapshot.bookmark !== null) { + range.createFromBookmark(this.editable, snapshot.bookmark).select(); + } + }; + /** + * @method rewind + * Rewinds the history stack back to the first snapshot taken. + * Leaves the stack intact, so that "Redo" can still be used. + */ + History.prototype.rewind = function () { + // Create snap shot if not yet recorded + if (this.$editable.html() !== this.stack[this.stackOffset].contents) { + this.recordUndo(); + } + // Return to the first available snapshot. + this.stackOffset = 0; + // Apply that snapshot. + this.applySnapshot(this.stack[this.stackOffset]); + }; + /** + * @method reset + * Resets the history stack completely; reverting to an empty editor. + */ + History.prototype.reset = function () { + // Clear the stack. + this.stack = []; + // Restore stackOffset to its original value. + this.stackOffset = -1; + // Clear the editable area. + this.$editable.html(''); + // Record our first snapshot (of nothing). + this.recordUndo(); + }; + /** + * undo + */ + History.prototype.undo = function () { + // Create snap shot if not yet recorded + if (this.$editable.html() !== this.stack[this.stackOffset].contents) { + this.recordUndo(); + } + if (this.stackOffset > 0) { + this.stackOffset--; + this.applySnapshot(this.stack[this.stackOffset]); + } + }; + /** + * redo + */ + History.prototype.redo = function () { + if (this.stack.length - 1 > this.stackOffset) { + this.stackOffset++; + this.applySnapshot(this.stack[this.stackOffset]); + } + }; + /** + * recorded undo + */ + History.prototype.recordUndo = function () { + this.stackOffset++; + // Wash out stack after stackOffset + if (this.stack.length > this.stackOffset) { + this.stack = this.stack.slice(0, this.stackOffset); + } + // Create new snapshot and push it to the end + this.stack.push(this.makeSnapshot()); + }; + return History; +}()); + +var Style = /** @class */ (function () { + function Style() { + } + /** + * @method jQueryCSS + * + * [workaround] for old jQuery + * passing an array of style properties to .css() + * will result in an object of property-value pairs. + * (compability with version < 1.9) + * + * @private + * @param {jQuery} $obj + * @param {Array} propertyNames - An array of one or more CSS properties. + * @return {Object} + */ + Style.prototype.jQueryCSS = function ($obj, propertyNames) { + if (env.jqueryVersion < 1.9) { + var result_1 = {}; + $$1.each(propertyNames, function (idx, propertyName) { + result_1[propertyName] = $obj.css(propertyName); + }); + return result_1; + } + return $obj.css(propertyNames); + }; + /** + * returns style object from node + * + * @param {jQuery} $node + * @return {Object} + */ + Style.prototype.fromNode = function ($node) { + var properties = ['font-family', 'font-size', 'text-align', 'list-style-type', 'line-height']; + var styleInfo = this.jQueryCSS($node, properties) || {}; + styleInfo['font-size'] = parseInt(styleInfo['font-size'], 10); + return styleInfo; + }; + /** + * paragraph level style + * + * @param {WrappedRange} rng + * @param {Object} styleInfo + */ + Style.prototype.stylePara = function (rng, styleInfo) { + $$1.each(rng.nodes(dom.isPara, { + includeAncestor: true + }), function (idx, para) { + $$1(para).css(styleInfo); + }); + }; + /** + * insert and returns styleNodes on range. + * + * @param {WrappedRange} rng + * @param {Object} [options] - options for styleNodes + * @param {String} [options.nodeName] - default: `SPAN` + * @param {Boolean} [options.expandClosestSibling] - default: `false` + * @param {Boolean} [options.onlyPartialContains] - default: `false` + * @return {Node[]} + */ + Style.prototype.styleNodes = function (rng, options) { + rng = rng.splitText(); + var nodeName = (options && options.nodeName) || 'SPAN'; + var expandClosestSibling = !!(options && options.expandClosestSibling); + var onlyPartialContains = !!(options && options.onlyPartialContains); + if (rng.isCollapsed()) { + return [rng.insertNode(dom.create(nodeName))]; + } + var pred = dom.makePredByNodeName(nodeName); + var nodes = rng.nodes(dom.isText, { + fullyContains: true + }).map(function (text) { + return dom.singleChildAncestor(text, pred) || dom.wrap(text, nodeName); + }); + if (expandClosestSibling) { + if (onlyPartialContains) { + var nodesInRange_1 = rng.nodes(); + // compose with partial contains predication + pred = func.and(pred, function (node) { + return lists.contains(nodesInRange_1, node); + }); + } + return nodes.map(function (node) { + var siblings = dom.withClosestSiblings(node, pred); + var head = lists.head(siblings); + var tails = lists.tail(siblings); + $$1.each(tails, function (idx, elem) { + dom.appendChildNodes(head, elem.childNodes); + dom.remove(elem); + }); + return lists.head(siblings); + }); + } + else { + return nodes; + } + }; + /** + * get current style on cursor + * + * @param {WrappedRange} rng + * @return {Object} - object contains style properties. + */ + Style.prototype.current = function (rng) { + var $cont = $$1(!dom.isElement(rng.sc) ? rng.sc.parentNode : rng.sc); + var styleInfo = this.fromNode($cont); + // document.queryCommandState for toggle state + // [workaround] prevent Firefox nsresult: "0x80004005 (NS_ERROR_FAILURE)" + try { + styleInfo = $$1.extend(styleInfo, { + 'font-bold': document.queryCommandState('bold') ? 'bold' : 'normal', + 'font-italic': document.queryCommandState('italic') ? 'italic' : 'normal', + 'font-underline': document.queryCommandState('underline') ? 'underline' : 'normal', + 'font-subscript': document.queryCommandState('subscript') ? 'subscript' : 'normal', + 'font-superscript': document.queryCommandState('superscript') ? 'superscript' : 'normal', + 'font-strikethrough': document.queryCommandState('strikethrough') ? 'strikethrough' : 'normal', + 'font-family': document.queryCommandValue('fontname') || styleInfo['font-family'] + }); + } + catch (e) { } + // list-style-type to list-style(unordered, ordered) + if (!rng.isOnList()) { + styleInfo['list-style'] = 'none'; + } + else { + var orderedTypes = ['circle', 'disc', 'disc-leading-zero', 'square']; + var isUnordered = $$1.inArray(styleInfo['list-style-type'], orderedTypes) > -1; + styleInfo['list-style'] = isUnordered ? 'unordered' : 'ordered'; + } + var para = dom.ancestor(rng.sc, dom.isPara); + if (para && para.style['line-height']) { + styleInfo['line-height'] = para.style.lineHeight; + } + else { + var lineHeight = parseInt(styleInfo['line-height'], 10) / parseInt(styleInfo['font-size'], 10); + styleInfo['line-height'] = lineHeight.toFixed(1); + } + styleInfo.anchor = rng.isOnAnchor() && dom.ancestor(rng.sc, dom.isAnchor); + styleInfo.ancestors = dom.listAncestor(rng.sc, dom.isEditable); + styleInfo.range = rng; + return styleInfo; + }; + return Style; +}()); + +var Bullet = /** @class */ (function () { + function Bullet() { + } + /** + * toggle ordered list + */ + Bullet.prototype.insertOrderedList = function (editable) { + this.toggleList('OL', editable); + }; + /** + * toggle unordered list + */ + Bullet.prototype.insertUnorderedList = function (editable) { + this.toggleList('UL', editable); + }; + /** + * indent + */ + Bullet.prototype.indent = function (editable) { + var _this = this; + var rng = range.create(editable).wrapBodyInlineWithPara(); + var paras = rng.nodes(dom.isPara, { includeAncestor: true }); + var clustereds = lists.clusterBy(paras, func.peq2('parentNode')); + $$1.each(clustereds, function (idx, paras) { + var head = lists.head(paras); + if (dom.isLi(head)) { + _this.wrapList(paras, head.parentNode.nodeName); + } + else { + $$1.each(paras, function (idx, para) { + $$1(para).css('marginLeft', function (idx, val) { + return (parseInt(val, 10) || 0) + 25; + }); + }); + } + }); + rng.select(); + }; + /** + * outdent + */ + Bullet.prototype.outdent = function (editable) { + var _this = this; + var rng = range.create(editable).wrapBodyInlineWithPara(); + var paras = rng.nodes(dom.isPara, { includeAncestor: true }); + var clustereds = lists.clusterBy(paras, func.peq2('parentNode')); + $$1.each(clustereds, function (idx, paras) { + var head = lists.head(paras); + if (dom.isLi(head)) { + _this.releaseList([paras]); + } + else { + $$1.each(paras, function (idx, para) { + $$1(para).css('marginLeft', function (idx, val) { + val = (parseInt(val, 10) || 0); + return val > 25 ? val - 25 : ''; + }); + }); + } + }); + rng.select(); + }; + /** + * toggle list + * + * @param {String} listName - OL or UL + */ + Bullet.prototype.toggleList = function (listName, editable) { + var _this = this; + var rng = range.create(editable).wrapBodyInlineWithPara(); + var paras = rng.nodes(dom.isPara, { includeAncestor: true }); + var bookmark = rng.paraBookmark(paras); + var clustereds = lists.clusterBy(paras, func.peq2('parentNode')); + // paragraph to list + if (lists.find(paras, dom.isPurePara)) { + var wrappedParas_1 = []; + $$1.each(clustereds, function (idx, paras) { + wrappedParas_1 = wrappedParas_1.concat(_this.wrapList(paras, listName)); + }); + paras = wrappedParas_1; + // list to paragraph or change list style + } + else { + var diffLists = rng.nodes(dom.isList, { + includeAncestor: true + }).filter(function (listNode) { + return !$$1.nodeName(listNode, listName); + }); + if (diffLists.length) { + $$1.each(diffLists, function (idx, listNode) { + dom.replace(listNode, listName); + }); + } + else { + paras = this.releaseList(clustereds, true); + } + } + range.createFromParaBookmark(bookmark, paras).select(); + }; + /** + * @param {Node[]} paras + * @param {String} listName + * @return {Node[]} + */ + Bullet.prototype.wrapList = function (paras, listName) { + var head = lists.head(paras); + var last = lists.last(paras); + var prevList = dom.isList(head.previousSibling) && head.previousSibling; + var nextList = dom.isList(last.nextSibling) && last.nextSibling; + var listNode = prevList || dom.insertAfter(dom.create(listName || 'UL'), last); + // P to LI + paras = paras.map(function (para) { + return dom.isPurePara(para) ? dom.replace(para, 'LI') : para; + }); + // append to list(<ul>, <ol>) + dom.appendChildNodes(listNode, paras); + if (nextList) { + dom.appendChildNodes(listNode, lists.from(nextList.childNodes)); + dom.remove(nextList); + } + return paras; + }; + /** + * @method releaseList + * + * @param {Array[]} clustereds + * @param {Boolean} isEscapseToBody + * @return {Node[]} + */ + Bullet.prototype.releaseList = function (clustereds, isEscapseToBody) { + var releasedParas = []; + $$1.each(clustereds, function (idx, paras) { + var head = lists.head(paras); + var last = lists.last(paras); + var headList = isEscapseToBody ? dom.lastAncestor(head, dom.isList) : head.parentNode; + var lastList = headList.childNodes.length > 1 ? dom.splitTree(headList, { + node: last.parentNode, + offset: dom.position(last) + 1 + }, { + isSkipPaddingBlankHTML: true + }) : null; + var middleList = dom.splitTree(headList, { + node: head.parentNode, + offset: dom.position(head) + }, { + isSkipPaddingBlankHTML: true + }); + paras = isEscapseToBody ? dom.listDescendant(middleList, dom.isLi) + : lists.from(middleList.childNodes).filter(dom.isLi); + // LI to P + if (isEscapseToBody || !dom.isList(headList.parentNode)) { + paras = paras.map(function (para) { + return dom.replace(para, 'P'); + }); + } + $$1.each(lists.from(paras).reverse(), function (idx, para) { + dom.insertAfter(para, headList); + }); + // remove empty lists + var rootLists = lists.compact([headList, middleList, lastList]); + $$1.each(rootLists, function (idx, rootList) { + var listNodes = [rootList].concat(dom.listDescendant(rootList, dom.isList)); + $$1.each(listNodes.reverse(), function (idx, listNode) { + if (!dom.nodeLength(listNode)) { + dom.remove(listNode, true); + } + }); + }); + releasedParas = releasedParas.concat(paras); + }); + return releasedParas; + }; + return Bullet; +}()); + +/** + * @class editing.Typing + * + * Typing + * + */ +var Typing = /** @class */ (function () { + function Typing() { + // a Bullet instance to toggle lists off + this.bullet = new Bullet(); + } + /** + * insert tab + * + * @param {WrappedRange} rng + * @param {Number} tabsize + */ + Typing.prototype.insertTab = function (rng, tabsize) { + var tab = dom.createText(new Array(tabsize + 1).join(dom.NBSP_CHAR)); + rng = rng.deleteContents(); + rng.insertNode(tab, true); + rng = range.create(tab, tabsize); + rng.select(); + }; + /** + * insert paragraph + */ + Typing.prototype.insertParagraph = function (editable) { + var rng = range.create(editable); + // deleteContents on range. + rng = rng.deleteContents(); + // Wrap range if it needs to be wrapped by paragraph + rng = rng.wrapBodyInlineWithPara(); + // finding paragraph + var splitRoot = dom.ancestor(rng.sc, dom.isPara); + var nextPara; + // on paragraph: split paragraph + if (splitRoot) { + // if it is an empty line with li + if (dom.isEmpty(splitRoot) && dom.isLi(splitRoot)) { + // toogle UL/OL and escape + this.bullet.toggleList(splitRoot.parentNode.nodeName); + return; + // if it is an empty line with para on blockquote + } + else if (dom.isEmpty(splitRoot) && dom.isPara(splitRoot) && dom.isBlockquote(splitRoot.parentNode)) { + // escape blockquote + dom.insertAfter(splitRoot, splitRoot.parentNode); + nextPara = splitRoot; + // if new line has content (not a line break) + } + else { + nextPara = dom.splitTree(splitRoot, rng.getStartPoint()); + var emptyAnchors = dom.listDescendant(splitRoot, dom.isEmptyAnchor); + emptyAnchors = emptyAnchors.concat(dom.listDescendant(nextPara, dom.isEmptyAnchor)); + $$1.each(emptyAnchors, function (idx, anchor) { + dom.remove(anchor); + }); + // replace empty heading, pre or custom-made styleTag with P tag + if ((dom.isHeading(nextPara) || dom.isPre(nextPara) || dom.isCustomStyleTag(nextPara)) && dom.isEmpty(nextPara)) { + nextPara = dom.replace(nextPara, 'p'); + } + } + // no paragraph: insert empty paragraph + } + else { + var next = rng.sc.childNodes[rng.so]; + nextPara = $$1(dom.emptyPara)[0]; + if (next) { + rng.sc.insertBefore(nextPara, next); + } + else { + rng.sc.appendChild(nextPara); + } + } + range.create(nextPara, 0).normalize().select().scrollIntoView(editable); + }; + return Typing; +}()); + +/** + * @class Create a virtual table to create what actions to do in change. + * @param {object} startPoint Cell selected to apply change. + * @param {enum} where Where change will be applied Row or Col. Use enum: TableResultAction.where + * @param {enum} action Action to be applied. Use enum: TableResultAction.requestAction + * @param {object} domTable Dom element of table to make changes. + */ +var TableResultAction = function (startPoint, where, action, domTable) { + var _startPoint = { 'colPos': 0, 'rowPos': 0 }; + var _virtualTable = []; + var _actionCellList = []; + /// /////////////////////////////////////////// + // Private functions + /// /////////////////////////////////////////// + /** + * Set the startPoint of action. + */ + function setStartPoint() { + if (!startPoint || !startPoint.tagName || (startPoint.tagName.toLowerCase() !== 'td' && startPoint.tagName.toLowerCase() !== 'th')) { + console.error('Impossible to identify start Cell point.', startPoint); + return; + } + _startPoint.colPos = startPoint.cellIndex; + if (!startPoint.parentElement || !startPoint.parentElement.tagName || startPoint.parentElement.tagName.toLowerCase() !== 'tr') { + console.error('Impossible to identify start Row point.', startPoint); + return; + } + _startPoint.rowPos = startPoint.parentElement.rowIndex; + } + /** + * Define virtual table position info object. + * + * @param {int} rowIndex Index position in line of virtual table. + * @param {int} cellIndex Index position in column of virtual table. + * @param {object} baseRow Row affected by this position. + * @param {object} baseCell Cell affected by this position. + * @param {bool} isSpan Inform if it is an span cell/row. + */ + function setVirtualTablePosition(rowIndex, cellIndex, baseRow, baseCell, isRowSpan, isColSpan, isVirtualCell) { + var objPosition = { + 'baseRow': baseRow, + 'baseCell': baseCell, + 'isRowSpan': isRowSpan, + 'isColSpan': isColSpan, + 'isVirtual': isVirtualCell + }; + if (!_virtualTable[rowIndex]) { + _virtualTable[rowIndex] = []; + } + _virtualTable[rowIndex][cellIndex] = objPosition; + } + /** + * Create action cell object. + * + * @param {object} virtualTableCellObj Object of specific position on virtual table. + * @param {enum} resultAction Action to be applied in that item. + */ + function getActionCell(virtualTableCellObj, resultAction, virtualRowPosition, virtualColPosition) { + return { + 'baseCell': virtualTableCellObj.baseCell, + 'action': resultAction, + 'virtualTable': { + 'rowIndex': virtualRowPosition, + 'cellIndex': virtualColPosition + } + }; + } + /** + * Recover free index of row to append Cell. + * + * @param {int} rowIndex Index of row to find free space. + * @param {int} cellIndex Index of cell to find free space in table. + */ + function recoverCellIndex(rowIndex, cellIndex) { + if (!_virtualTable[rowIndex]) { + return cellIndex; + } + if (!_virtualTable[rowIndex][cellIndex]) { + return cellIndex; + } + var newCellIndex = cellIndex; + while (_virtualTable[rowIndex][newCellIndex]) { + newCellIndex++; + if (!_virtualTable[rowIndex][newCellIndex]) { + return newCellIndex; + } + } + } + /** + * Recover info about row and cell and add information to virtual table. + * + * @param {object} row Row to recover information. + * @param {object} cell Cell to recover information. + */ + function addCellInfoToVirtual(row, cell) { + var cellIndex = recoverCellIndex(row.rowIndex, cell.cellIndex); + var cellHasColspan = (cell.colSpan > 1); + var cellHasRowspan = (cell.rowSpan > 1); + var isThisSelectedCell = (row.rowIndex === _startPoint.rowPos && cell.cellIndex === _startPoint.colPos); + setVirtualTablePosition(row.rowIndex, cellIndex, row, cell, cellHasRowspan, cellHasColspan, false); + // Add span rows to virtual Table. + var rowspanNumber = cell.attributes.rowSpan ? parseInt(cell.attributes.rowSpan.value, 10) : 0; + if (rowspanNumber > 1) { + for (var rp = 1; rp < rowspanNumber; rp++) { + var rowspanIndex = row.rowIndex + rp; + adjustStartPoint(rowspanIndex, cellIndex, cell, isThisSelectedCell); + setVirtualTablePosition(rowspanIndex, cellIndex, row, cell, true, cellHasColspan, true); + } + } + // Add span cols to virtual table. + var colspanNumber = cell.attributes.colSpan ? parseInt(cell.attributes.colSpan.value, 10) : 0; + if (colspanNumber > 1) { + for (var cp = 1; cp < colspanNumber; cp++) { + var cellspanIndex = recoverCellIndex(row.rowIndex, (cellIndex + cp)); + adjustStartPoint(row.rowIndex, cellspanIndex, cell, isThisSelectedCell); + setVirtualTablePosition(row.rowIndex, cellspanIndex, row, cell, cellHasRowspan, true, true); + } + } + } + /** + * Process validation and adjust of start point if needed + * + * @param {int} rowIndex + * @param {int} cellIndex + * @param {object} cell + * @param {bool} isSelectedCell + */ + function adjustStartPoint(rowIndex, cellIndex, cell, isSelectedCell) { + if (rowIndex === _startPoint.rowPos && _startPoint.colPos >= cell.cellIndex && cell.cellIndex <= cellIndex && !isSelectedCell) { + _startPoint.colPos++; + } + } + /** + * Create virtual table of cells with all cells, including span cells. + */ + function createVirtualTable() { + var rows = domTable.rows; + for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) { + var cells = rows[rowIndex].cells; + for (var cellIndex = 0; cellIndex < cells.length; cellIndex++) { + addCellInfoToVirtual(rows[rowIndex], cells[cellIndex]); + } + } + } + /** + * Get action to be applied on the cell. + * + * @param {object} cell virtual table cell to apply action + */ + function getDeleteResultActionToCell(cell) { + switch (where) { + case TableResultAction.where.Column: + if (cell.isColSpan) { + return TableResultAction.resultAction.SubtractSpanCount; + } + break; + case TableResultAction.where.Row: + if (!cell.isVirtual && cell.isRowSpan) { + return TableResultAction.resultAction.AddCell; + } + else if (cell.isRowSpan) { + return TableResultAction.resultAction.SubtractSpanCount; + } + break; + } + return TableResultAction.resultAction.RemoveCell; + } + /** + * Get action to be applied on the cell. + * + * @param {object} cell virtual table cell to apply action + */ + function getAddResultActionToCell(cell) { + switch (where) { + case TableResultAction.where.Column: + if (cell.isColSpan) { + return TableResultAction.resultAction.SumSpanCount; + } + else if (cell.isRowSpan && cell.isVirtual) { + return TableResultAction.resultAction.Ignore; + } + break; + case TableResultAction.where.Row: + if (cell.isRowSpan) { + return TableResultAction.resultAction.SumSpanCount; + } + else if (cell.isColSpan && cell.isVirtual) { + return TableResultAction.resultAction.Ignore; + } + break; + } + return TableResultAction.resultAction.AddCell; + } + function init() { + setStartPoint(); + createVirtualTable(); + } + /// /////////////////////////////////////////// + // Public functions + /// /////////////////////////////////////////// + /** + * Recover array os what to do in table. + */ + this.getActionList = function () { + var fixedRow = (where === TableResultAction.where.Row) ? _startPoint.rowPos : -1; + var fixedCol = (where === TableResultAction.where.Column) ? _startPoint.colPos : -1; + var actualPosition = 0; + var canContinue = true; + while (canContinue) { + var rowPosition = (fixedRow >= 0) ? fixedRow : actualPosition; + var colPosition = (fixedCol >= 0) ? fixedCol : actualPosition; + var row = _virtualTable[rowPosition]; + if (!row) { + canContinue = false; + return _actionCellList; + } + var cell = row[colPosition]; + if (!cell) { + canContinue = false; + return _actionCellList; + } + // Define action to be applied in this cell + var resultAction = TableResultAction.resultAction.Ignore; + switch (action) { + case TableResultAction.requestAction.Add: + resultAction = getAddResultActionToCell(cell); + break; + case TableResultAction.requestAction.Delete: + resultAction = getDeleteResultActionToCell(cell); + break; + } + _actionCellList.push(getActionCell(cell, resultAction, rowPosition, colPosition)); + actualPosition++; + } + return _actionCellList; + }; + init(); +}; +/** +* +* Where action occours enum. +*/ +TableResultAction.where = { 'Row': 0, 'Column': 1 }; +/** +* +* Requested action to apply enum. +*/ +TableResultAction.requestAction = { 'Add': 0, 'Delete': 1 }; +/** +* +* Result action to be executed enum. +*/ +TableResultAction.resultAction = { 'Ignore': 0, 'SubtractSpanCount': 1, 'RemoveCell': 2, 'AddCell': 3, 'SumSpanCount': 4 }; +/** + * + * @class editing.Table + * + * Table + * + */ +var Table = /** @class */ (function () { + function Table() { + } + /** + * handle tab key + * + * @param {WrappedRange} rng + * @param {Boolean} isShift + */ + Table.prototype.tab = function (rng, isShift) { + var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); + var table = dom.ancestor(cell, dom.isTable); + var cells = dom.listDescendant(table, dom.isCell); + var nextCell = lists[isShift ? 'prev' : 'next'](cells, cell); + if (nextCell) { + range.create(nextCell, 0).select(); + } + }; + /** + * Add a new row + * + * @param {WrappedRange} rng + * @param {String} position (top/bottom) + * @return {Node} + */ + Table.prototype.addRow = function (rng, position) { + var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); + var currentTr = $$1(cell).closest('tr'); + var trAttributes = this.recoverAttributes(currentTr); + var html = $$1('<tr' + trAttributes + '></tr>'); + var vTable = new TableResultAction(cell, TableResultAction.where.Row, TableResultAction.requestAction.Add, $$1(currentTr).closest('table')[0]); + var actions = vTable.getActionList(); + for (var idCell = 0; idCell < actions.length; idCell++) { + var currentCell = actions[idCell]; + var tdAttributes = this.recoverAttributes(currentCell.baseCell); + switch (currentCell.action) { + case TableResultAction.resultAction.AddCell: + html.append('<td' + tdAttributes + '>' + dom.blank + '</td>'); + break; + case TableResultAction.resultAction.SumSpanCount: + if (position === 'top') { + var baseCellTr = currentCell.baseCell.parent; + var isTopFromRowSpan = (!baseCellTr ? 0 : currentCell.baseCell.closest('tr').rowIndex) <= currentTr[0].rowIndex; + if (isTopFromRowSpan) { + var newTd = $$1('<div></div>').append($$1('<td' + tdAttributes + '>' + dom.blank + '</td>').removeAttr('rowspan')).html(); + html.append(newTd); + break; + } + } + var rowspanNumber = parseInt(currentCell.baseCell.rowSpan, 10); + rowspanNumber++; + currentCell.baseCell.setAttribute('rowSpan', rowspanNumber); + break; + } + } + if (position === 'top') { + currentTr.before(html); + } + else { + var cellHasRowspan = (cell.rowSpan > 1); + if (cellHasRowspan) { + var lastTrIndex = currentTr[0].rowIndex + (cell.rowSpan - 2); + $$1($$1(currentTr).parent().find('tr')[lastTrIndex]).after($$1(html)); + return; + } + currentTr.after(html); + } + }; + /** + * Add a new col + * + * @param {WrappedRange} rng + * @param {String} position (left/right) + * @return {Node} + */ + Table.prototype.addCol = function (rng, position) { + var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); + var row = $$1(cell).closest('tr'); + var rowsGroup = $$1(row).siblings(); + rowsGroup.push(row); + var vTable = new TableResultAction(cell, TableResultAction.where.Column, TableResultAction.requestAction.Add, $$1(row).closest('table')[0]); + var actions = vTable.getActionList(); + for (var actionIndex = 0; actionIndex < actions.length; actionIndex++) { + var currentCell = actions[actionIndex]; + var tdAttributes = this.recoverAttributes(currentCell.baseCell); + switch (currentCell.action) { + case TableResultAction.resultAction.AddCell: + if (position === 'right') { + $$1(currentCell.baseCell).after('<td' + tdAttributes + '>' + dom.blank + '</td>'); + } + else { + $$1(currentCell.baseCell).before('<td' + tdAttributes + '>' + dom.blank + '</td>'); + } + break; + case TableResultAction.resultAction.SumSpanCount: + if (position === 'right') { + var colspanNumber = parseInt(currentCell.baseCell.colSpan, 10); + colspanNumber++; + currentCell.baseCell.setAttribute('colSpan', colspanNumber); + } + else { + $$1(currentCell.baseCell).before('<td' + tdAttributes + '>' + dom.blank + '</td>'); + } + break; + } + } + }; + /* + * Copy attributes from element. + * + * @param {object} Element to recover attributes. + * @return {string} Copied string elements. + */ + Table.prototype.recoverAttributes = function (el) { + var resultStr = ''; + if (!el) { + return resultStr; + } + var attrList = el.attributes || []; + for (var i = 0; i < attrList.length; i++) { + if (attrList[i].name.toLowerCase() === 'id') { + continue; + } + if (attrList[i].specified) { + resultStr += ' ' + attrList[i].name + '=\'' + attrList[i].value + '\''; + } + } + return resultStr; + }; + /** + * Delete current row + * + * @param {WrappedRange} rng + * @return {Node} + */ + Table.prototype.deleteRow = function (rng) { + var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); + var row = $$1(cell).closest('tr'); + var cellPos = row.children('td, th').index($$1(cell)); + var rowPos = row[0].rowIndex; + var vTable = new TableResultAction(cell, TableResultAction.where.Row, TableResultAction.requestAction.Delete, $$1(row).closest('table')[0]); + var actions = vTable.getActionList(); + for (var actionIndex = 0; actionIndex < actions.length; actionIndex++) { + if (!actions[actionIndex]) { + continue; + } + var baseCell = actions[actionIndex].baseCell; + var virtualPosition = actions[actionIndex].virtualTable; + var hasRowspan = (baseCell.rowSpan && baseCell.rowSpan > 1); + var rowspanNumber = (hasRowspan) ? parseInt(baseCell.rowSpan, 10) : 0; + switch (actions[actionIndex].action) { + case TableResultAction.resultAction.Ignore: + continue; + case TableResultAction.resultAction.AddCell: + var nextRow = row.next('tr')[0]; + if (!nextRow) { + continue; + } + var cloneRow = row[0].cells[cellPos]; + if (hasRowspan) { + if (rowspanNumber > 2) { + rowspanNumber--; + nextRow.insertBefore(cloneRow, nextRow.cells[cellPos]); + nextRow.cells[cellPos].setAttribute('rowSpan', rowspanNumber); + nextRow.cells[cellPos].innerHTML = ''; + } + else if (rowspanNumber === 2) { + nextRow.insertBefore(cloneRow, nextRow.cells[cellPos]); + nextRow.cells[cellPos].removeAttribute('rowSpan'); + nextRow.cells[cellPos].innerHTML = ''; + } + } + continue; + case TableResultAction.resultAction.SubtractSpanCount: + if (hasRowspan) { + if (rowspanNumber > 2) { + rowspanNumber--; + baseCell.setAttribute('rowSpan', rowspanNumber); + if (virtualPosition.rowIndex !== rowPos && baseCell.cellIndex === cellPos) { + baseCell.innerHTML = ''; + } + } + else if (rowspanNumber === 2) { + baseCell.removeAttribute('rowSpan'); + if (virtualPosition.rowIndex !== rowPos && baseCell.cellIndex === cellPos) { + baseCell.innerHTML = ''; + } + } + } + continue; + case TableResultAction.resultAction.RemoveCell: + // Do not need remove cell because row will be deleted. + continue; + } + } + row.remove(); + }; + /** + * Delete current col + * + * @param {WrappedRange} rng + * @return {Node} + */ + Table.prototype.deleteCol = function (rng) { + var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); + var row = $$1(cell).closest('tr'); + var cellPos = row.children('td, th').index($$1(cell)); + var vTable = new TableResultAction(cell, TableResultAction.where.Column, TableResultAction.requestAction.Delete, $$1(row).closest('table')[0]); + var actions = vTable.getActionList(); + for (var actionIndex = 0; actionIndex < actions.length; actionIndex++) { + if (!actions[actionIndex]) { + continue; + } + switch (actions[actionIndex].action) { + case TableResultAction.resultAction.Ignore: + continue; + case TableResultAction.resultAction.SubtractSpanCount: + var baseCell = actions[actionIndex].baseCell; + var hasColspan = (baseCell.colSpan && baseCell.colSpan > 1); + if (hasColspan) { + var colspanNumber = (baseCell.colSpan) ? parseInt(baseCell.colSpan, 10) : 0; + if (colspanNumber > 2) { + colspanNumber--; + baseCell.setAttribute('colSpan', colspanNumber); + if (baseCell.cellIndex === cellPos) { + baseCell.innerHTML = ''; + } + } + else if (colspanNumber === 2) { + baseCell.removeAttribute('colSpan'); + if (baseCell.cellIndex === cellPos) { + baseCell.innerHTML = ''; + } + } + } + continue; + case TableResultAction.resultAction.RemoveCell: + dom.remove(actions[actionIndex].baseCell, true); + continue; + } + } + }; + /** + * create empty table element + * + * @param {Number} rowCount + * @param {Number} colCount + * @return {Node} + */ + Table.prototype.createTable = function (colCount, rowCount, options) { + var tds = []; + var tdHTML; + for (var idxCol = 0; idxCol < colCount; idxCol++) { + tds.push('<td>' + dom.blank + '</td>'); + } + tdHTML = tds.join(''); + var trs = []; + var trHTML; + for (var idxRow = 0; idxRow < rowCount; idxRow++) { + trs.push('<tr>' + tdHTML + '</tr>'); + } + trHTML = trs.join(''); + var $table = $$1('<table>' + trHTML + '</table>'); + if (options && options.tableClassName) { + $table.addClass(options.tableClassName); + } + return $table[0]; + }; + /** + * Delete current table + * + * @param {WrappedRange} rng + * @return {Node} + */ + Table.prototype.deleteTable = function (rng) { + var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); + $$1(cell).closest('table').remove(); + }; + return Table; +}()); + +var KEY_BOGUS = 'bogus'; +/** + * @class Editor + */ +var Editor = /** @class */ (function () { + function Editor(context) { + var _this = this; + this.context = context; + this.$note = context.layoutInfo.note; + this.$editor = context.layoutInfo.editor; + this.$editable = context.layoutInfo.editable; + this.options = context.options; + this.lang = this.options.langInfo; + this.editable = this.$editable[0]; + this.lastRange = null; + this.style = new Style(); + this.table = new Table(); + this.typing = new Typing(); + this.bullet = new Bullet(); + this.history = new History(this.$editable); + this.context.memo('help.undo', this.lang.help.undo); + this.context.memo('help.redo', this.lang.help.redo); + this.context.memo('help.tab', this.lang.help.tab); + this.context.memo('help.untab', this.lang.help.untab); + this.context.memo('help.insertParagraph', this.lang.help.insertParagraph); + this.context.memo('help.insertOrderedList', this.lang.help.insertOrderedList); + this.context.memo('help.insertUnorderedList', this.lang.help.insertUnorderedList); + this.context.memo('help.indent', this.lang.help.indent); + this.context.memo('help.outdent', this.lang.help.outdent); + this.context.memo('help.formatPara', this.lang.help.formatPara); + this.context.memo('help.insertHorizontalRule', this.lang.help.insertHorizontalRule); + this.context.memo('help.fontName', this.lang.help.fontName); + // native commands(with execCommand), generate function for execCommand + var commands = [ + 'bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript', + 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', + 'formatBlock', 'removeFormat', 'backColor' + ]; + for (var idx = 0, len = commands.length; idx < len; idx++) { + this[commands[idx]] = (function (sCmd) { + return function (value) { + _this.beforeCommand(); + document.execCommand(sCmd, false, value); + _this.afterCommand(true); + }; + })(commands[idx]); + this.context.memo('help.' + commands[idx], this.lang.help[commands[idx]]); + } + this.fontName = this.wrapCommand(function (value) { + return _this.fontStyling('font-family', "\'" + value + "\'"); + }); + this.fontSize = this.wrapCommand(function (value) { + return _this.fontStyling('font-size', value + 'px'); + }); + for (var idx = 1; idx <= 6; idx++) { + this['formatH' + idx] = (function (idx) { + return function () { + _this.formatBlock('H' + idx); + }; + })(idx); + this.context.memo('help.formatH' + idx, this.lang.help['formatH' + idx]); + } + + this.insertParagraph = this.wrapCommand(function () { + _this.typing.insertParagraph(_this.editable); + }); + this.insertOrderedList = this.wrapCommand(function () { + _this.bullet.insertOrderedList(_this.editable); + }); + this.insertUnorderedList = this.wrapCommand(function () { + _this.bullet.insertUnorderedList(_this.editable); + }); + this.indent = this.wrapCommand(function () { + _this.bullet.indent(_this.editable); + }); + this.outdent = this.wrapCommand(function () { + _this.bullet.outdent(_this.editable); + }); + /** + * insertNode + * insert node + * @param {Node} node + */ + this.insertNode = this.wrapCommand(function (node) { + if (_this.isLimited($$1(node).text().length)) { + return; + } + var rng = _this.createRange(); + rng.insertNode(node); + range.createFromNodeAfter(node).select(); + }); + /** + * insert text + * @param {String} text + */ + this.insertText = this.wrapCommand(function (text) { + if (_this.isLimited(text.length)) { + return; + } + var rng = _this.createRange(); + var textNode = rng.insertNode(dom.createText(text)); + range.create(textNode, dom.nodeLength(textNode)).select(); + }); + /** + * paste HTML + * @param {String} markup + */ + this.pasteHTML = this.wrapCommand(function (markup) { + if (_this.isLimited(markup.length)) { + return; + } + var contents = _this.createRange().pasteHTML(markup); + range.createFromNodeAfter(lists.last(contents)).select(); + }); + /** + * formatBlock + * + * @param {String} tagName + */ + this.formatBlock = this.wrapCommand(function (tagName, $target) { + var onApplyCustomStyle = _this.options.callbacks.onApplyCustomStyle; + if (onApplyCustomStyle) { + onApplyCustomStyle.call(_this, $target, _this.context, _this.onFormatBlock); + } + else { + _this.onFormatBlock(tagName, $target); + } + }); + /** + * insert horizontal rule + */ + this.insertHorizontalRule = this.wrapCommand(function () { + var hrNode = _this.createRange().insertNode(dom.create('HR')); + if (hrNode.nextSibling) { + range.create(hrNode.nextSibling, 0).normalize().select(); + } + }); + /** + * lineHeight + * @param {String} value + */ + this.lineHeight = this.wrapCommand(function (value) { + _this.style.stylePara(_this.createRange(), { + lineHeight: value + }); + }); + /** + * create link (command) + * + * @param {Object} linkInfo + */ + this.createLink = this.wrapCommand(function (linkInfo) { + var linkUrl = linkInfo.url; + var linkText = linkInfo.text; + var isNewWindow = linkInfo.isNewWindow; + var rng = linkInfo.range || _this.createRange(); + var isTextChanged = rng.toString() !== linkText; + // handle spaced urls from input + if (typeof linkUrl === 'string') { + linkUrl = linkUrl.trim(); + } + if (_this.options.onCreateLink) { + linkUrl = _this.options.onCreateLink(linkUrl); + } + else { + // if url doesn't match an URL schema, set http:// as default + linkUrl = /^[A-Za-z][A-Za-z0-9+-.]*\:[\/\/]?/.test(linkUrl) + ? linkUrl : 'http://' + linkUrl; + } + var anchors = []; + if (isTextChanged) { + rng = rng.deleteContents(); + var anchor = rng.insertNode($$1('<A>' + linkText + '</A>')[0]); + anchors.push(anchor); + } + else { + anchors = _this.style.styleNodes(rng, { + nodeName: 'A', + expandClosestSibling: true, + onlyPartialContains: true + }); + } + $$1.each(anchors, function (idx, anchor) { + $$1(anchor).attr('href', linkUrl); + if (isNewWindow) { + $$1(anchor).attr('target', '_blank'); + } + else { + $$1(anchor).removeAttr('target'); + } + }); + var startRange = range.createFromNodeBefore(lists.head(anchors)); + var startPoint = startRange.getStartPoint(); + var endRange = range.createFromNodeAfter(lists.last(anchors)); + var endPoint = endRange.getEndPoint(); + range.create(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset).select(); + }); + /** + * setting color + * + * @param {Object} sObjColor color code + * @param {String} sObjColor.foreColor foreground color + * @param {String} sObjColor.backColor background color + */ + this.color = this.wrapCommand(function (colorInfo) { + var foreColor = colorInfo.foreColor; + var backColor = colorInfo.backColor; + if (foreColor) { + document.execCommand('foreColor', false, foreColor); + } + if (backColor) { + document.execCommand('backColor', false, backColor); + } + }); + /** + * Set foreground color + * + * @param {String} colorCode foreground color code + */ + this.foreColor = this.wrapCommand(function (colorInfo) { + document.execCommand('styleWithCSS', false, true); + document.execCommand('foreColor', false, colorInfo); + }); + /** + * insert Table + * + * @param {String} dimension of table (ex : "5x5") + */ + this.insertTable = this.wrapCommand(function (dim) { + var dimension = dim.split('x'); + var rng = _this.createRange().deleteContents(); + rng.insertNode(_this.table.createTable(dimension[0], dimension[1], _this.options)); + }); + /** + * remove media object and Figure Elements if media object is img with Figure. + */ + this.removeMedia = this.wrapCommand(function () { + var $target = $$1(_this.restoreTarget()).parent(); + if ($target.parent('figure').length) { + $target.parent('figure').remove(); + } + else { + $target = $$1(_this.restoreTarget()).detach(); + } + _this.context.triggerEvent('media.delete', $target, _this.$editable); + }); + /** + * float me + * + * @param {String} value + */ + this.floatMe = this.wrapCommand(function (value) { + var $target = $$1(_this.restoreTarget()); + $target.toggleClass('note-float-left', value === 'left'); + $target.toggleClass('note-float-right', value === 'right'); + $target.css('float', value); + }); + /** + * resize overlay element + * @param {String} value + */ + this.resize = this.wrapCommand(function (value) { + var $target = $$1(_this.restoreTarget()); + $target.css({ + width: value * 100 + '%', + height: '' + }); + }); + } + Editor.prototype.initialize = function () { + var _this = this; + // bind custom events + this.$editable.on('keydown', function (event) { + if (event.keyCode === key.code.ENTER) { + _this.context.triggerEvent('enter', event); + } + _this.context.triggerEvent('keydown', event); + if (!event.isDefaultPrevented()) { + if (_this.options.shortcuts) { + _this.handleKeyMap(event); + } + else { + _this.preventDefaultEditableShortCuts(event); + } + } + if (_this.isLimited(1, event)) { + return false; + } + }).on('keyup', function (event) { + _this.context.triggerEvent('keyup', event); + }).on('focus', function (event) { + _this.context.triggerEvent('focus', event); + }).on('blur', function (event) { + _this.context.triggerEvent('blur', event); + }).on('mousedown', function (event) { + _this.context.triggerEvent('mousedown', event); + }).on('mouseup', function (event) { + _this.context.triggerEvent('mouseup', event); + }).on('scroll', function (event) { + _this.context.triggerEvent('scroll', event); + }).on('paste', function (event) { + _this.context.triggerEvent('paste', event); + }); + // init content before set event + this.$editable.html(dom.html(this.$note) || dom.emptyPara); + this.$editable.on(env.inputEventName, func.debounce(function () { + _this.context.triggerEvent('change', _this.$editable.html()); + }, 100)); + this.$editor.on('focusin', function (event) { + _this.context.triggerEvent('focusin', event); + }).on('focusout', function (event) { + _this.context.triggerEvent('focusout', event); + }); + if (!this.options.airMode) { + if (this.options.width) { + this.$editor.outerWidth(this.options.width); + } + if (this.options.height) { + this.$editable.outerHeight(this.options.height); + } + if (this.options.maxHeight) { + this.$editable.css('max-height', this.options.maxHeight); + } + if (this.options.minHeight) { + this.$editable.css('min-height', this.options.minHeight); + } + } + this.history.recordUndo(); + }; + Editor.prototype.destroy = function () { + this.$editable.off(); + }; + Editor.prototype.handleKeyMap = function (event) { + var keyMap = this.options.keyMap[env.isMac ? 'mac' : 'pc']; + var keys = []; + if (event.metaKey) { + keys.push('CMD'); + } + if (event.ctrlKey && !event.altKey) { + keys.push('CTRL'); + } + if (event.shiftKey) { + keys.push('SHIFT'); + } + var keyName = key.nameFromCode[event.keyCode]; + if (keyName) { + keys.push(keyName); + } + var eventName = keyMap[keys.join('+')]; + if (eventName) { + if (this.context.invoke(eventName) !== false) { + event.preventDefault(); + } + } + else if (key.isEdit(event.keyCode)) { + this.afterCommand(); + } + }; + Editor.prototype.preventDefaultEditableShortCuts = function (event) { + // B(Bold, 66) / I(Italic, 73) / U(Underline, 85) + if ((event.ctrlKey || event.metaKey) && + lists.contains([66, 73, 85], event.keyCode)) { + event.preventDefault(); + } + }; + Editor.prototype.isLimited = function (pad, event) { + pad = pad || 0; + if (typeof event !== 'undefined') { + if (key.isMove(event.keyCode) || + (event.ctrlKey || event.metaKey) || + lists.contains([key.code.BACKSPACE, key.code.DELETE], event.keyCode)) { + return false; + } + } + if (this.options.maxTextLength > 0) { + if ((this.$editable.text().length + pad) >= this.options.maxTextLength) { + return true; + } + } + return false; + }; + /** + * create range + * @return {WrappedRange} + */ + Editor.prototype.createRange = function () { + this.focus(); + return range.create(this.editable); + }; + /** + * saveRange + * + * save current range + * + * @param {Boolean} [thenCollapse=false] + */ + Editor.prototype.saveRange = function (thenCollapse) { + this.lastRange = this.createRange(); + if (thenCollapse) { + this.lastRange.collapse().select(); + } + }; + /** + * restoreRange + * + * restore lately range + */ + Editor.prototype.restoreRange = function () { + if (this.lastRange) { + this.lastRange.select(); + this.focus(); + } + }; + Editor.prototype.saveTarget = function (node) { + this.$editable.data('target', node); + }; + Editor.prototype.clearTarget = function () { + this.$editable.removeData('target'); + }; + Editor.prototype.restoreTarget = function () { + return this.$editable.data('target'); + }; + /** + * currentStyle + * + * current style + * @return {Object|Boolean} unfocus + */ + Editor.prototype.currentStyle = function () { + var rng = range.create(); + if (rng) { + rng = rng.normalize(); + } + return rng ? this.style.current(rng) : this.style.fromNode(this.$editable); + }; + /** + * style from node + * + * @param {jQuery} $node + * @return {Object} + */ + Editor.prototype.styleFromNode = function ($node) { + return this.style.fromNode($node); + }; + /** + * undo + */ + Editor.prototype.undo = function () { + this.context.triggerEvent('before.command', this.$editable.html()); + this.history.undo(); + this.context.triggerEvent('change', this.$editable.html()); + }; + /** + * redo + */ + Editor.prototype.redo = function () { + this.context.triggerEvent('before.command', this.$editable.html()); + this.history.redo(); + this.context.triggerEvent('change', this.$editable.html()); + }; + /** + * before command + */ + Editor.prototype.beforeCommand = function () { + this.context.triggerEvent('before.command', this.$editable.html()); + // keep focus on editable before command execution + this.focus(); + }; + /** + * after command + * @param {Boolean} isPreventTrigger + */ + Editor.prototype.afterCommand = function (isPreventTrigger) { + this.history.recordUndo(); + if (!isPreventTrigger) { + this.context.triggerEvent('change', this.$editable.html()); + } + }; + /** + * handle tab key + */ + Editor.prototype.tab = function () { + var rng = this.createRange(); + if (rng.isCollapsed() && rng.isOnCell()) { + this.table.tab(rng); + } + else { + if (this.options.tabSize === 0) { + return false; + } + if (!this.isLimited(this.options.tabSize)) { + this.beforeCommand(); + this.typing.insertTab(rng, this.options.tabSize); + this.afterCommand(); + } + } + }; + /** + * handle shift+tab key + */ + Editor.prototype.untab = function () { + var rng = this.createRange(); + if (rng.isCollapsed() && rng.isOnCell()) { + this.table.tab(rng, true); + } + else { + if (this.options.tabSize === 0) { + return false; + } + } + }; + /** + * run given function between beforeCommand and afterCommand + */ + Editor.prototype.wrapCommand = function (fn) { + var _this = this; + return function () { + _this.beforeCommand(); + fn.apply(_this, arguments); + _this.afterCommand(); + }; + }; + /** + * insert image + * + * @param {String} src + * @param {String|Function} param + * @return {Promise} + */ + Editor.prototype.insertImage = function (src, param) { + var _this = this; + return createImage(src, param).then(function ($image) { + _this.beforeCommand(); + if (typeof param === 'function') { + param($image); + } + else { + if (typeof param === 'string') { + $image.attr('data-filename', param); + } + $image.css('width', Math.min(_this.$editable.width(), $image.width())); + } + $image.show(); + range.create(_this.editable).insertNode($image[0]); + range.createFromNodeAfter($image[0]).select(); + _this.afterCommand(); + }).fail(function (e) { + _this.context.triggerEvent('image.upload.error', e); + }); + }; + /** + * insertImages + * @param {File[]} files + */ + Editor.prototype.insertImages = function (files) { + var _this = this; + $$1.each(files, function (idx, file) { + var filename = file.name; + if (_this.options.maximumImageFileSize && _this.options.maximumImageFileSize < file.size) { + _this.context.triggerEvent('image.upload.error', _this.lang.image.maximumFileSizeError); + } + else { + readFileAsDataURL(file).then(function (dataURL) { + return _this.insertImage(dataURL, filename); + }).fail(function () { + _this.context.triggerEvent('image.upload.error'); + }); + } + }); + }; + /** + * insertImagesOrCallback + * @param {File[]} files + */ + Editor.prototype.insertImagesOrCallback = function (files) { + var callbacks = this.options.callbacks; + // If onImageUpload this.options setted + if (callbacks.onImageUpload) { + this.context.triggerEvent('image.upload', files); + // else insert Image as dataURL + } + else { + this.insertImages(files); + } + }; + /** + * return selected plain text + * @return {String} text + */ + Editor.prototype.getSelectedText = function () { + var rng = this.createRange(); + // if range on anchor, expand range with anchor + if (rng.isOnAnchor()) { + rng = range.createFromNode(dom.ancestor(rng.sc, dom.isAnchor)); + } + return rng.toString(); + }; + Editor.prototype.onFormatBlock = function (tagName, $target) { + // [workaround] for MSIE, IE need `<` + tagName = env.isMSIE ? '<' + tagName + '>' : tagName; + document.execCommand('FormatBlock', false, tagName); + // support custom class + if ($target && $target.length) { + var className = $target[0].className || ''; + if (className) { + var currentRange = this.createRange(); + var $parent = $$1([currentRange.sc, currentRange.ec]).closest(tagName); + $parent.addClass(className); + } + } + }; + Editor.prototype.formatPara = function () { + this.formatBlock('P'); + }; + Editor.prototype.fontStyling = function (target, value) { + var rng = this.createRange(); + if (rng) { + var spans = this.style.styleNodes(rng); + $$1(spans).css(target, value); + // [workaround] added styled bogus span for style + // - also bogus character needed for cursor position + if (rng.isCollapsed()) { + var firstSpan = lists.head(spans); + if (firstSpan && !dom.nodeLength(firstSpan)) { + firstSpan.innerHTML = dom.ZERO_WIDTH_NBSP_CHAR; + range.createFromNodeAfter(firstSpan.firstChild).select(); + this.$editable.data(KEY_BOGUS, firstSpan); + } + } + } + }; + /** + * unlink + * + * @type command + */ + Editor.prototype.unlink = function () { + var rng = this.createRange(); + if (rng.isOnAnchor()) { + var anchor = dom.ancestor(rng.sc, dom.isAnchor); + rng = range.createFromNode(anchor); + rng.select(); + this.beforeCommand(); + document.execCommand('unlink'); + this.afterCommand(); + } + }; + /** + * returns link info + * + * @return {Object} + * @return {WrappedRange} return.range + * @return {String} return.text + * @return {Boolean} [return.isNewWindow=true] + * @return {String} [return.url=""] + */ + Editor.prototype.getLinkInfo = function () { + var rng = this.createRange().expand(dom.isAnchor); + // Get the first anchor on range(for edit). + var $anchor = $$1(lists.head(rng.nodes(dom.isAnchor))); + var linkInfo = { + range: rng, + text: rng.toString(), + url: $anchor.length ? $anchor.attr('href') : '' + }; + // Define isNewWindow when anchor exists. + if ($anchor.length) { + linkInfo.isNewWindow = $anchor.attr('target') === '_blank'; + } + return linkInfo; + }; + Editor.prototype.addRow = function (position) { + var rng = this.createRange(this.$editable); + if (rng.isCollapsed() && rng.isOnCell()) { + this.beforeCommand(); + this.table.addRow(rng, position); + this.afterCommand(); + } + }; + Editor.prototype.addCol = function (position) { + var rng = this.createRange(this.$editable); + if (rng.isCollapsed() && rng.isOnCell()) { + this.beforeCommand(); + this.table.addCol(rng, position); + this.afterCommand(); + } + }; + Editor.prototype.deleteRow = function () { + var rng = this.createRange(this.$editable); + if (rng.isCollapsed() && rng.isOnCell()) { + this.beforeCommand(); + this.table.deleteRow(rng); + this.afterCommand(); + } + }; + Editor.prototype.deleteCol = function () { + var rng = this.createRange(this.$editable); + if (rng.isCollapsed() && rng.isOnCell()) { + this.beforeCommand(); + this.table.deleteCol(rng); + this.afterCommand(); + } + }; + Editor.prototype.deleteTable = function () { + var rng = this.createRange(this.$editable); + if (rng.isCollapsed() && rng.isOnCell()) { + this.beforeCommand(); + this.table.deleteTable(rng); + this.afterCommand(); + } + }; + /** + * @param {Position} pos + * @param {jQuery} $target - target element + * @param {Boolean} [bKeepRatio] - keep ratio + */ + Editor.prototype.resizeTo = function (pos, $target, bKeepRatio) { + var imageSize; + if (bKeepRatio) { + var newRatio = pos.y / pos.x; + var ratio = $target.data('ratio'); + imageSize = { + width: ratio > newRatio ? pos.x : pos.y / ratio, + height: ratio > newRatio ? pos.x * ratio : pos.y + }; + } + else { + imageSize = { + width: pos.x, + height: pos.y + }; + } + $target.css(imageSize); + }; + /** + * returns whether editable area has focus or not. + */ + Editor.prototype.hasFocus = function () { + return this.$editable.is(':focus'); + }; + /** + * set focus + */ + Editor.prototype.focus = function () { + // [workaround] Screen will move when page is scolled in IE. + // - do focus when not focused + if (!this.hasFocus()) { + this.$editable.focus(); + } + }; + /** + * returns whether contents is empty or not. + * @return {Boolean} + */ + Editor.prototype.isEmpty = function () { + return dom.isEmpty(this.$editable[0]) || dom.emptyPara === this.$editable.html(); + }; + /** + * Removes all contents and restores the editable instance to an _emptyPara_. + */ + Editor.prototype.empty = function () { + this.context.invoke('code', dom.emptyPara); + }; + return Editor; +}()); + +var Clipboard = /** @class */ (function () { + function Clipboard(context) { + this.context = context; + this.$editable = context.layoutInfo.editable; + } + Clipboard.prototype.initialize = function () { + this.$editable.on('paste', this.pasteByEvent.bind(this)); + }; + /** + * paste by clipboard event + * + * @param {Event} event + */ + Clipboard.prototype.pasteByEvent = function (event) { + var clipboardData = event.originalEvent.clipboardData; + if (clipboardData && clipboardData.items && clipboardData.items.length) { + var item = lists.head(clipboardData.items); + if (item.kind === 'file' && item.type.indexOf('image/') !== -1) { + this.context.invoke('editor.insertImagesOrCallback', [item.getAsFile()]); + } + this.context.invoke('editor.afterCommand'); + } + }; + return Clipboard; +}()); + +var Dropzone = /** @class */ (function () { + function Dropzone(context) { + this.context = context; + this.$eventListener = $$1(document); + this.$editor = context.layoutInfo.editor; + this.$editable = context.layoutInfo.editable; + this.options = context.options; + this.lang = this.options.langInfo; + this.documentEventHandlers = {}; + this.$dropzone = $$1([ + '<div class="note-dropzone">', + ' <div class="note-dropzone-message"/>', + '</div>' + ].join('')).prependTo(this.$editor); + } + /** + * attach Drag and Drop Events + */ + Dropzone.prototype.initialize = function () { + if (this.options.disableDragAndDrop) { + // prevent default drop event + this.documentEventHandlers.onDrop = function (e) { + e.preventDefault(); + }; + // do not consider outside of dropzone + this.$eventListener = this.$dropzone; + this.$eventListener.on('drop', this.documentEventHandlers.onDrop); + } + else { + this.attachDragAndDropEvent(); + } + }; + /** + * attach Drag and Drop Events + */ + Dropzone.prototype.attachDragAndDropEvent = function () { + var _this = this; + var collection = $$1(); + var $dropzoneMessage = this.$dropzone.find('.note-dropzone-message'); + this.documentEventHandlers.onDragenter = function (e) { + var isCodeview = _this.context.invoke('codeview.isActivated'); + var hasEditorSize = _this.$editor.width() > 0 && _this.$editor.height() > 0; + if (!isCodeview && !collection.length && hasEditorSize) { + _this.$editor.addClass('dragover'); + _this.$dropzone.width(_this.$editor.width()); + _this.$dropzone.height(_this.$editor.height()); + $dropzoneMessage.text(_this.lang.image.dragImageHere); + } + collection = collection.add(e.target); + }; + this.documentEventHandlers.onDragleave = function (e) { + collection = collection.not(e.target); + if (!collection.length) { + _this.$editor.removeClass('dragover'); + } + }; + this.documentEventHandlers.onDrop = function () { + collection = $$1(); + _this.$editor.removeClass('dragover'); + }; + // show dropzone on dragenter when dragging a object to document + // -but only if the editor is visible, i.e. has a positive width and height + this.$eventListener.on('dragenter', this.documentEventHandlers.onDragenter) + .on('dragleave', this.documentEventHandlers.onDragleave) + .on('drop', this.documentEventHandlers.onDrop); + // change dropzone's message on hover. + this.$dropzone.on('dragenter', function () { + _this.$dropzone.addClass('hover'); + $dropzoneMessage.text(_this.lang.image.dropImage); + }).on('dragleave', function () { + _this.$dropzone.removeClass('hover'); + $dropzoneMessage.text(_this.lang.image.dragImageHere); + }); + // attach dropImage + this.$dropzone.on('drop', function (event) { + var dataTransfer = event.originalEvent.dataTransfer; + // stop the browser from opening the dropped content + event.preventDefault(); + if (dataTransfer && dataTransfer.files && dataTransfer.files.length) { + _this.$editable.focus(); + _this.context.invoke('editor.insertImagesOrCallback', dataTransfer.files); + } + else { + $$1.each(dataTransfer.types, function (idx, type) { + var content = dataTransfer.getData(type); + if (type.toLowerCase().indexOf('text') > -1) { + _this.context.invoke('editor.pasteHTML', content); + } + else { + $$1(content).each(function (idx, item) { + _this.context.invoke('editor.insertNode', item); + }); + } + }); + } + }).on('dragover', false); // prevent default dragover event + }; + Dropzone.prototype.destroy = function () { + var _this = this; + Object.keys(this.documentEventHandlers).forEach(function (key) { + _this.$eventListener.off(key.substr(2).toLowerCase(), _this.documentEventHandlers[key]); + }); + this.documentEventHandlers = {}; + }; + return Dropzone; +}()); + +var CodeMirror; +if (env.hasCodeMirror) { + if (env.isSupportAmd) { + require(['codemirror'], function (cm) { + CodeMirror = cm; + }); + } + else { + CodeMirror = window.CodeMirror; + } +} +/** + * @class Codeview + */ +var CodeView = /** @class */ (function () { + function CodeView(context) { + this.context = context; + this.$editor = context.layoutInfo.editor; + this.$editable = context.layoutInfo.editable; + this.$codable = context.layoutInfo.codable; + this.options = context.options; + } + CodeView.prototype.sync = function () { + var isCodeview = this.isActivated(); + if (isCodeview && env.hasCodeMirror) { + this.$codable.data('cmEditor').save(); + } + }; + /** + * @return {Boolean} + */ + CodeView.prototype.isActivated = function () { + return this.$editor.hasClass('codeview'); + }; + /** + * toggle codeview + */ + CodeView.prototype.toggle = function () { + if (this.isActivated()) { + this.deactivate(); + } + else { + this.activate(); + } + this.context.triggerEvent('codeview.toggled'); + }; + /** + * activate code view + */ + CodeView.prototype.activate = function () { + var _this = this; + this.$codable.val(dom.html(this.$editable, this.options.prettifyHtml)); + this.$codable.height(this.$editable.height()); + this.context.invoke('toolbar.updateCodeview', true); + this.$editor.addClass('codeview'); + this.$codable.focus(); + // activate CodeMirror as codable + if (env.hasCodeMirror) { + var cmEditor_1 = CodeMirror.fromTextArea(this.$codable[0], this.options.codemirror); + // CodeMirror TernServer + if (this.options.codemirror.tern) { + var server_1 = new CodeMirror.TernServer(this.options.codemirror.tern); + cmEditor_1.ternServer = server_1; + cmEditor_1.on('cursorActivity', function (cm) { + server_1.updateArgHints(cm); + }); + } + cmEditor_1.on('blur', function (event) { + _this.context.triggerEvent('blur.codeview', cmEditor_1.getValue(), event); + }); + // CodeMirror hasn't Padding. + cmEditor_1.setSize(null, this.$editable.outerHeight()); + this.$codable.data('cmEditor', cmEditor_1); + } + else { + this.$codable.on('blur', function (event) { + _this.context.triggerEvent('blur.codeview', _this.$codable.val(), event); + }); + } + }; + /** + * deactivate code view + */ + CodeView.prototype.deactivate = function () { + // deactivate CodeMirror as codable + if (env.hasCodeMirror) { + var cmEditor = this.$codable.data('cmEditor'); + this.$codable.val(cmEditor.getValue()); + cmEditor.toTextArea(); + } + var value = dom.value(this.$codable, this.options.prettifyHtml) || dom.emptyPara; + var isChange = this.$editable.html() !== value; + this.$editable.html(value); + this.$editable.height(this.options.height ? this.$codable.height() : 'auto'); + this.$editor.removeClass('codeview'); + if (isChange) { + this.context.triggerEvent('change', this.$editable.html(), this.$editable); + } + this.$editable.focus(); + this.context.invoke('toolbar.updateCodeview', false); + }; + CodeView.prototype.destroy = function () { + if (this.isActivated()) { + this.deactivate(); + } + }; + return CodeView; +}()); + +var EDITABLE_PADDING = 24; +var Statusbar = /** @class */ (function () { + function Statusbar(context) { + this.$document = $$1(document); + this.$statusbar = context.layoutInfo.statusbar; + this.$editable = context.layoutInfo.editable; + this.options = context.options; + } + Statusbar.prototype.initialize = function () { + var _this = this; + if (this.options.airMode || this.options.disableResizeEditor) { + this.destroy(); + return; + } + this.$statusbar.on('mousedown', function (event) { + event.preventDefault(); + event.stopPropagation(); + var editableTop = _this.$editable.offset().top - _this.$document.scrollTop(); + var onMouseMove = function (event) { + var height = event.clientY - (editableTop + EDITABLE_PADDING); + height = (_this.options.minheight > 0) ? Math.max(height, _this.options.minheight) : height; + height = (_this.options.maxHeight > 0) ? Math.min(height, _this.options.maxHeight) : height; + _this.$editable.height(height); + }; + _this.$document.on('mousemove', onMouseMove).one('mouseup', function () { + _this.$document.off('mousemove', onMouseMove); + }); + }); + }; + Statusbar.prototype.destroy = function () { + this.$statusbar.off(); + }; + return Statusbar; +}()); + +var Fullscreen = /** @class */ (function () { + function Fullscreen(context) { + var _this = this; + this.context = context; + this.$editor = context.layoutInfo.editor; + this.$toolbar = context.layoutInfo.toolbar; + this.$editable = context.layoutInfo.editable; + this.$codable = context.layoutInfo.codable; + this.$window = $$1(window); + this.$scrollbar = $$1('html, body'); + this.onResize = function () { + _this.resizeTo({ + h: _this.$window.height() - _this.$toolbar.outerHeight() + }); + }; + } + Fullscreen.prototype.resizeTo = function (size) { + this.$editable.css('height', size.h); + this.$codable.css('height', size.h); + if (this.$codable.data('cmeditor')) { + this.$codable.data('cmeditor').setsize(null, size.h); + } + }; + /** + * toggle fullscreen + */ + Fullscreen.prototype.toggle = function () { + this.$editor.toggleClass('fullscreen'); + if (this.isFullscreen()) { + this.$editable.data('orgHeight', this.$editable.css('height')); + this.$window.on('resize', this.onResize).trigger('resize'); + this.$scrollbar.css('overflow', 'hidden'); + } + else { + this.$window.off('resize', this.onResize); + this.resizeTo({ h: this.$editable.data('orgHeight') }); + this.$scrollbar.css('overflow', 'visible'); + } + this.context.invoke('toolbar.updateFullscreen', this.isFullscreen()); + }; + Fullscreen.prototype.isFullscreen = function () { + return this.$editor.hasClass('fullscreen'); + }; + return Fullscreen; +}()); + +var Handle = /** @class */ (function () { + function Handle(context) { + var _this = this; + this.context = context; + this.$document = $$1(document); + this.$editingArea = context.layoutInfo.editingArea; + this.options = context.options; + this.lang = this.options.langInfo; + this.events = { + 'summernote.mousedown': function (we, e) { + if (_this.update(e.target)) { + e.preventDefault(); + } + }, + 'summernote.keyup summernote.scroll summernote.change summernote.dialog.shown': function () { + _this.update(); + }, + 'summernote.disable': function () { + _this.hide(); + }, + 'summernote.codeview.toggled': function () { + _this.update(); + } + }; + } + Handle.prototype.initialize = function () { + var _this = this; + this.$handle = $$1([ + '<div class="note-handle">', + '<div class="note-control-selection">', + '<div class="note-control-selection-bg"></div>', + '<div class="note-control-holder note-control-nw"></div>', + '<div class="note-control-holder note-control-ne"></div>', + '<div class="note-control-holder note-control-sw"></div>', + '<div class="', + (this.options.disableResizeImage ? 'note-control-holder' : 'note-control-sizing'), + ' note-control-se"></div>', + (this.options.disableResizeImage ? '' : '<div class="note-control-selection-info"></div>'), + '</div>', + '</div>' + ].join('')).prependTo(this.$editingArea); + this.$handle.on('mousedown', function (event) { + if (dom.isControlSizing(event.target)) { + event.preventDefault(); + event.stopPropagation(); + var $target_1 = _this.$handle.find('.note-control-selection').data('target'); + var posStart_1 = $target_1.offset(); + var scrollTop_1 = _this.$document.scrollTop(); + var onMouseMove_1 = function (event) { + _this.context.invoke('editor.resizeTo', { + x: event.clientX - posStart_1.left, + y: event.clientY - (posStart_1.top - scrollTop_1) + }, $target_1, !event.shiftKey); + _this.update($target_1[0]); + }; + _this.$document + .on('mousemove', onMouseMove_1) + .one('mouseup', function (e) { + e.preventDefault(); + _this.$document.off('mousemove', onMouseMove_1); + _this.context.invoke('editor.afterCommand'); + }); + if (!$target_1.data('ratio')) { + $target_1.data('ratio', $target_1.height() / $target_1.width()); + } + } + }); + // Listen for scrolling on the handle overlay. + this.$handle.on('wheel', function (e) { + e.preventDefault(); + _this.update(); + }); + }; + Handle.prototype.destroy = function () { + this.$handle.remove(); + }; + Handle.prototype.update = function (target) { + if (this.context.isDisabled()) { + return false; + } + var isImage = dom.isImg(target); + var $selection = this.$handle.find('.note-control-selection'); + this.context.invoke('imagePopover.update', target); + if (isImage) { + var $image = $$1(target); + var position = $image.position(); + var pos = { + left: position.left + parseInt($image.css('marginLeft'), 10), + top: position.top + parseInt($image.css('marginTop'), 10) + }; + // exclude margin + var imageSize = { + w: $image.outerWidth(false), + h: $image.outerHeight(false) + }; + $selection.css({ + display: 'block', + left: pos.left, + top: pos.top, + width: imageSize.w, + height: imageSize.h + }).data('target', $image); // save current image element. + var origImageObj = new Image(); + origImageObj.src = $image.attr('src'); + var sizingText = imageSize.w + 'x' + imageSize.h + ' (' + this.lang.image.original + ': ' + origImageObj.width + 'x' + origImageObj.height + ')'; + $selection.find('.note-control-selection-info').text(sizingText); + this.context.invoke('editor.saveTarget', target); + } + else { + this.hide(); + } + return isImage; + }; + /** + * hide + * + * @param {jQuery} $handle + */ + Handle.prototype.hide = function () { + this.context.invoke('editor.clearTarget'); + this.$handle.children().hide(); + }; + return Handle; +}()); + +var defaultScheme = 'http://'; +var linkPattern = /^([A-Za-z][A-Za-z0-9+-.]*\:[\/\/]?|mailto:[A-Z0-9._%+-]+@)?(www\.)?(.+)$/i; +var AutoLink = /** @class */ (function () { + function AutoLink(context) { + var _this = this; + this.context = context; + this.events = { + 'summernote.keyup': function (we, e) { + if (!e.isDefaultPrevented()) { + _this.handleKeyup(e); + } + }, + 'summernote.keydown': function (we, e) { + _this.handleKeydown(e); + } + }; + } + AutoLink.prototype.initialize = function () { + this.lastWordRange = null; + }; + AutoLink.prototype.destroy = function () { + this.lastWordRange = null; + }; + AutoLink.prototype.replace = function () { + if (!this.lastWordRange) { + return; + } + var keyword = this.lastWordRange.toString(); + var match = keyword.match(linkPattern); + if (match && (match[1] || match[2])) { + var link = match[1] ? keyword : defaultScheme + keyword; + var node = $$1('<a />').html(keyword).attr('href', link)[0]; + this.lastWordRange.insertNode(node); + this.lastWordRange = null; + this.context.invoke('editor.focus'); + } + }; + AutoLink.prototype.handleKeydown = function (e) { + if (lists.contains([key.code.ENTER, key.code.SPACE], e.keyCode)) { + var wordRange = this.context.invoke('editor.createRange').getWordRange(); + this.lastWordRange = wordRange; + } + }; + AutoLink.prototype.handleKeyup = function (e) { + if (lists.contains([key.code.ENTER, key.code.SPACE], e.keyCode)) { + this.replace(); + } + }; + return AutoLink; +}()); + +/** + * textarea auto sync. + */ +var AutoSync = /** @class */ (function () { + function AutoSync(context) { + var _this = this; + this.$note = context.layoutInfo.note; + this.events = { + 'summernote.change': function () { + _this.$note.val(context.invoke('code')); + } + }; + } + AutoSync.prototype.shouldInitialize = function () { + return dom.isTextarea(this.$note[0]); + }; + return AutoSync; +}()); + +var Placeholder = /** @class */ (function () { + function Placeholder(context) { + var _this = this; + this.context = context; + this.$editingArea = context.layoutInfo.editingArea; + this.options = context.options; + this.events = { + 'summernote.init summernote.change': function () { + _this.update(); + }, + 'summernote.codeview.toggled': function () { + _this.update(); + } + }; + } + Placeholder.prototype.shouldInitialize = function () { + return !!this.options.placeholder; + }; + Placeholder.prototype.initialize = function () { + var _this = this; + this.$placeholder = $$1('<div class="note-placeholder">'); + this.$placeholder.on('click', function () { + _this.context.invoke('focus'); + }).text(this.options.placeholder).prependTo(this.$editingArea); + this.update(); + }; + Placeholder.prototype.destroy = function () { + this.$placeholder.remove(); + }; + Placeholder.prototype.update = function () { + var isShow = !this.context.invoke('codeview.isActivated') && this.context.invoke('editor.isEmpty'); + this.$placeholder.toggle(isShow); + }; + return Placeholder; +}()); + +var Buttons = /** @class */ (function () { + function Buttons(context) { + this.ui = $$1.summernote.ui; + this.context = context; + this.$toolbar = context.layoutInfo.toolbar; + this.options = context.options; + this.lang = this.options.langInfo; + this.invertedKeyMap = func.invertObject(this.options.keyMap[env.isMac ? 'mac' : 'pc']); + } + Buttons.prototype.representShortcut = function (editorMethod) { + var shortcut = this.invertedKeyMap[editorMethod]; + if (!this.options.shortcuts || !shortcut) { + return ''; + } + if (env.isMac) { + shortcut = shortcut.replace('CMD', '⌘').replace('SHIFT', '⇧'); + } + shortcut = shortcut.replace('BACKSLASH', '\\') + .replace('SLASH', '/') + .replace('LEFTBRACKET', '[') + .replace('RIGHTBRACKET', ']'); + return ' (' + shortcut + ')'; + }; + Buttons.prototype.button = function (o) { + if (!this.options.tooltip && o.tooltip) { + delete o.tooltip; + } + o.container = this.options.container; + return this.ui.button(o); + }; + Buttons.prototype.initialize = function () { + this.addToolbarButtons(); + this.addImagePopoverButtons(); + this.addLinkPopoverButtons(); + this.addTablePopoverButtons(); + this.fontInstalledMap = {}; + }; + Buttons.prototype.destroy = function () { + delete this.fontInstalledMap; + }; + Buttons.prototype.isFontInstalled = function (name) { + if (!this.fontInstalledMap.hasOwnProperty(name)) { + this.fontInstalledMap[name] = env.isFontInstalled(name) || + lists.contains(this.options.fontNamesIgnoreCheck, name); + } + return this.fontInstalledMap[name]; + }; + Buttons.prototype.isFontDeservedToAdd = function (name) { + var genericFamilies = ['sans-serif', 'serif', 'monospace', 'cursive', 'fantasy']; + name = name.toLowerCase(); + return ((name !== '') && this.isFontInstalled(name) && ($$1.inArray(name, genericFamilies) === -1)); + }; + Buttons.prototype.addToolbarButtons = function () { + var _this = this; + this.context.memo('button.style', function () { + return _this.ui.buttonGroup([ + _this.button({ + className: 'dropdown-toggle', + contents: _this.ui.dropdownButtonContents(_this.ui.icon(_this.options.icons.magic), _this.options), + tooltip: _this.lang.style.style, + data: { + toggle: 'dropdown' + } + }), + _this.ui.dropdown({ + className: 'dropdown-style', + items: _this.options.styleTags, + template: function (item) { + if (typeof item === 'string') { + item = { tag: item, title: (_this.lang.style.hasOwnProperty(item) ? _this.lang.style[item] : item) }; + } + var tag = item.tag; + var title = item.title; + var style = item.style ? ' style="' + item.style + '" ' : ''; + var className = item.className ? ' class="' + item.className + '"' : ''; + return '<' + tag + style + className + '>' + title + '</' + tag + '>'; + }, + click: _this.context.createInvokeHandler('editor.formatBlock') + }) + ]).render(); + }); + var _loop_1 = function (styleIdx, styleLen) { + var item = this_1.options.styleTags[styleIdx]; + this_1.context.memo('button.style.' + item, function () { + return _this.button({ + className: 'note-btn-style-' + item, + contents: '<div data-value="' + item + '">' + item.toUpperCase() + '</div>', + tooltip: item.toUpperCase(), + click: _this.context.createInvokeHandler('editor.formatBlock') + }).render(); + }); + }; + var this_1 = this; + for (var styleIdx = 0, styleLen = this.options.styleTags.length; styleIdx < styleLen; styleIdx++) { + _loop_1(styleIdx, styleLen); + } + this.context.memo('button.bold', function () { + return _this.button({ + className: 'note-btn-bold', + contents: _this.ui.icon(_this.options.icons.bold), + tooltip: _this.lang.font.bold + _this.representShortcut('bold'), + click: _this.context.createInvokeHandlerAndUpdateState('editor.bold') + }).render(); + }); + this.context.memo('button.italic', function () { + return _this.button({ + className: 'note-btn-italic', + contents: _this.ui.icon(_this.options.icons.italic), + tooltip: _this.lang.font.italic + _this.representShortcut('italic'), + click: _this.context.createInvokeHandlerAndUpdateState('editor.italic') + }).render(); + }); + this.context.memo('button.underline', function () { + return _this.button({ + className: 'note-btn-underline', + contents: _this.ui.icon(_this.options.icons.underline), + tooltip: _this.lang.font.underline + _this.representShortcut('underline'), + click: _this.context.createInvokeHandlerAndUpdateState('editor.underline') + }).render(); + }); + this.context.memo('button.clear', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.eraser), + tooltip: _this.lang.font.clear + _this.representShortcut('removeFormat'), + click: _this.context.createInvokeHandler('editor.removeFormat') + }).render(); + }); + this.context.memo('button.strikethrough', function () { + return _this.button({ + className: 'note-btn-strikethrough', + contents: _this.ui.icon(_this.options.icons.strikethrough), + tooltip: _this.lang.font.strikethrough + _this.representShortcut('strikethrough'), + click: _this.context.createInvokeHandlerAndUpdateState('editor.strikethrough') + }).render(); + }); + this.context.memo('button.superscript', function () { + return _this.button({ + className: 'note-btn-superscript', + contents: _this.ui.icon(_this.options.icons.superscript), + tooltip: _this.lang.font.superscript, + click: _this.context.createInvokeHandlerAndUpdateState('editor.superscript') + }).render(); + }); + this.context.memo('button.subscript', function () { + return _this.button({ + className: 'note-btn-subscript', + contents: _this.ui.icon(_this.options.icons.subscript), + tooltip: _this.lang.font.subscript, + click: _this.context.createInvokeHandlerAndUpdateState('editor.subscript') + }).render(); + }); + this.context.memo('button.fontname', function () { + var styleInfo = _this.context.invoke('editor.currentStyle'); + // Add 'default' fonts into the fontnames array if not exist + $$1.each(styleInfo['font-family'].split(','), function (idx, fontname) { + fontname = fontname.trim().replace(/['"]+/g, ''); + if (_this.isFontDeservedToAdd(fontname)) { + if ($$1.inArray(fontname, _this.options.fontNames) === -1) { + _this.options.fontNames.push(fontname); + } + } + }); + return _this.ui.buttonGroup([ + _this.button({ + className: 'dropdown-toggle', + contents: _this.ui.dropdownButtonContents('<span class="note-current-fontname"/>', _this.options), + tooltip: _this.lang.font.name, + data: { + toggle: 'dropdown' + } + }), + _this.ui.dropdownCheck({ + className: 'dropdown-fontname', + checkClassName: _this.options.icons.menuCheck, + items: _this.options.fontNames.filter(_this.isFontInstalled.bind(_this)), + template: function (item) { + return '<span style="font-family: \'' + item + '\'">' + item + '</span>'; + }, + click: _this.context.createInvokeHandlerAndUpdateState('editor.fontName') + }) + ]).render(); + }); + this.context.memo('button.fontsize', function () { + return _this.ui.buttonGroup([ + _this.button({ + className: 'dropdown-toggle', + contents: _this.ui.dropdownButtonContents('<span class="note-current-fontsize"/>', _this.options), + tooltip: _this.lang.font.size, + data: { + toggle: 'dropdown' + } + }), + _this.ui.dropdownCheck({ + className: 'dropdown-fontsize', + checkClassName: _this.options.icons.menuCheck, + items: _this.options.fontSizes, + click: _this.context.createInvokeHandlerAndUpdateState('editor.fontSize') + }) + ]).render(); + }); + this.context.memo('button.color', function () { + return _this.ui.buttonGroup({ + className: 'note-color', + children: [ + _this.button({ + className: 'note-current-color-button', + contents: _this.ui.icon(_this.options.icons.font + ' note-recent-color'), + tooltip: _this.lang.color.recent, + click: function (e) { + var $button = $$1(e.currentTarget); + _this.context.invoke('editor.color', { + backColor: $button.attr('data-backColor'), + foreColor: $button.attr('data-foreColor') + }); + }, + callback: function ($button) { + var $recentColor = $button.find('.note-recent-color'); + $recentColor.css('background-color', '#FFFF00'); + $button.attr('data-backColor', '#FFFF00'); + } + }), + _this.button({ + className: 'dropdown-toggle', + contents: _this.ui.dropdownButtonContents('', _this.options), + tooltip: _this.lang.color.more, + data: { + toggle: 'dropdown' + } + }), + _this.ui.dropdown({ + items: [ + '<div class="note-palette">', + ' <div class="note-palette-title">' + _this.lang.color.background + '</div>', + ' <div>', + ' <button type="button" class="note-color-reset btn btn-light" data-event="backColor" data-value="inherit">', + _this.lang.color.transparent, + ' </button>', + ' </div>', + ' <div class="note-holder" data-event="backColor"/>', + '</div>', + '<div class="note-palette">', + ' <div class="note-palette-title">' + _this.lang.color.foreground + '</div>', + ' <div>', + ' <button type="button" class="note-color-reset btn btn-light" data-event="removeFormat" data-value="foreColor">', + _this.lang.color.resetToDefault, + ' </button>', + ' </div>', + ' <div class="note-holder" data-event="foreColor"/>', + '</div>' + ].join(''), + callback: function ($dropdown) { + $dropdown.find('.note-holder').each(function (idx, item) { + var $holder = $$1(item); + $holder.append(_this.ui.palette({ + colors: _this.options.colors, + eventName: $holder.data('event'), + container: _this.options.container, + tooltip: _this.options.tooltip + }).render()); + }); + }, + click: function (event) { + var $button = $$1(event.target); + var eventName = $button.data('event'); + var value = $button.data('value'); + if (eventName && value) { + var key = eventName === 'backColor' ? 'background-color' : 'color'; + var $color = $button.closest('.note-color').find('.note-recent-color'); + var $currentButton = $button.closest('.note-color').find('.note-current-color-button'); + $color.css(key, value); + $currentButton.attr('data-' + eventName, value); + _this.context.invoke('editor.' + eventName, value); + } + } + }) + ] + }).render(); + }); + this.context.memo('button.ul', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.unorderedlist), + tooltip: _this.lang.lists.unordered + _this.representShortcut('insertUnorderedList'), + click: _this.context.createInvokeHandler('editor.insertUnorderedList') + }).render(); + }); + this.context.memo('button.ol', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.orderedlist), + tooltip: _this.lang.lists.ordered + _this.representShortcut('insertOrderedList'), + click: _this.context.createInvokeHandler('editor.insertOrderedList') + }).render(); + }); + var justifyLeft = this.button({ + contents: this.ui.icon(this.options.icons.alignLeft), + tooltip: this.lang.paragraph.left + this.representShortcut('justifyLeft'), + click: this.context.createInvokeHandler('editor.justifyLeft') + }); + var justifyCenter = this.button({ + contents: this.ui.icon(this.options.icons.alignCenter), + tooltip: this.lang.paragraph.center + this.representShortcut('justifyCenter'), + click: this.context.createInvokeHandler('editor.justifyCenter') + }); + var justifyRight = this.button({ + contents: this.ui.icon(this.options.icons.alignRight), + tooltip: this.lang.paragraph.right + this.representShortcut('justifyRight'), + click: this.context.createInvokeHandler('editor.justifyRight') + }); + var justifyFull = this.button({ + contents: this.ui.icon(this.options.icons.alignJustify), + tooltip: this.lang.paragraph.justify + this.representShortcut('justifyFull'), + click: this.context.createInvokeHandler('editor.justifyFull') + }); + var outdent = this.button({ + contents: this.ui.icon(this.options.icons.outdent), + tooltip: this.lang.paragraph.outdent + this.representShortcut('outdent'), + click: this.context.createInvokeHandler('editor.outdent') + }); + var indent = this.button({ + contents: this.ui.icon(this.options.icons.indent), + tooltip: this.lang.paragraph.indent + this.representShortcut('indent'), + click: this.context.createInvokeHandler('editor.indent') + }); + this.context.memo('button.justifyLeft', func.invoke(justifyLeft, 'render')); + this.context.memo('button.justifyCenter', func.invoke(justifyCenter, 'render')); + this.context.memo('button.justifyRight', func.invoke(justifyRight, 'render')); + this.context.memo('button.justifyFull', func.invoke(justifyFull, 'render')); + this.context.memo('button.outdent', func.invoke(outdent, 'render')); + this.context.memo('button.indent', func.invoke(indent, 'render')); + this.context.memo('button.paragraph', function () { + return _this.ui.buttonGroup([ + _this.button({ + className: 'dropdown-toggle', + contents: _this.ui.dropdownButtonContents(_this.ui.icon(_this.options.icons.alignLeft), _this.options), + tooltip: _this.lang.paragraph.paragraph, + data: { + toggle: 'dropdown' + } + }), + _this.ui.dropdown([ + _this.ui.buttonGroup({ + className: 'note-align', + children: [justifyLeft, justifyCenter, justifyRight, justifyFull] + }), + _this.ui.buttonGroup({ + className: 'note-list', + children: [outdent, indent] + }) + ]) + ]).render(); + }); + this.context.memo('button.height', function () { + return _this.ui.buttonGroup([ + _this.button({ + className: 'dropdown-toggle', + contents: _this.ui.dropdownButtonContents(_this.ui.icon(_this.options.icons.textHeight), _this.options), + tooltip: _this.lang.font.height, + data: { + toggle: 'dropdown' + } + }), + _this.ui.dropdownCheck({ + items: _this.options.lineHeights, + checkClassName: _this.options.icons.menuCheck, + className: 'dropdown-line-height', + click: _this.context.createInvokeHandler('editor.lineHeight') + }) + ]).render(); + }); + this.context.memo('button.table', function () { + return _this.ui.buttonGroup([ + _this.button({ + className: 'dropdown-toggle', + contents: _this.ui.dropdownButtonContents(_this.ui.icon(_this.options.icons.table), _this.options), + tooltip: _this.lang.table.table, + data: { + toggle: 'dropdown' + } + }), + _this.ui.dropdown({ + className: 'note-table', + items: [ + '<div class="note-dimension-picker">', + ' <div class="note-dimension-picker-mousecatcher" data-event="insertTable" data-value="1x1"/>', + ' <div class="note-dimension-picker-highlighted"/>', + ' <div class="note-dimension-picker-unhighlighted"/>', + '</div>', + '<div class="note-dimension-display">1 x 1</div>' + ].join('') + }) + ], { + callback: function ($node) { + var $catcher = $node.find('.note-dimension-picker-mousecatcher'); + $catcher.css({ + width: _this.options.insertTableMaxSize.col + 'em', + height: _this.options.insertTableMaxSize.row + 'em' + }).mousedown(_this.context.createInvokeHandler('editor.insertTable')) + .on('mousemove', _this.tableMoveHandler.bind(_this)); + } + }).render(); + }); + this.context.memo('button.link', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.link), + tooltip: _this.lang.link.link + _this.representShortcut('linkDialog.show'), + click: _this.context.createInvokeHandler('linkDialog.show') + }).render(); + }); + this.context.memo('button.picture', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.picture), + tooltip: _this.lang.image.image, + click: _this.context.createInvokeHandler('imageDialog.show') + }).render(); + }); + this.context.memo('button.video', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.video), + tooltip: _this.lang.video.video, + click: _this.context.createInvokeHandler('videoDialog.show') + }).render(); + }); + this.context.memo('button.hr', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.minus), + tooltip: _this.lang.hr.insert + _this.representShortcut('insertHorizontalRule'), + click: _this.context.createInvokeHandler('editor.insertHorizontalRule') + }).render(); + }); + this.context.memo('button.fullscreen', function () { + return _this.button({ + className: 'btn-fullscreen', + contents: _this.ui.icon(_this.options.icons.arrowsAlt), + tooltip: _this.options.fullscreen, + click: _this.context.createInvokeHandler('fullscreen.toggle') + }).render(); + }); + this.context.memo('button.codeview', function () { + return _this.button({ + className: 'btn-codeview', + contents: _this.ui.icon(_this.options.icons.code), + tooltip: _this.options.codeview, + click: _this.context.createInvokeHandler('codeview.toggle') + }).render(); + }); + this.context.memo('button.redo', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.redo), + tooltip: _this.lang.history.redo + _this.representShortcut('redo'), + click: _this.context.createInvokeHandler('editor.redo') + }).render(); + }); + this.context.memo('button.undo', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.undo), + tooltip: _this.lang.history.undo + _this.representShortcut('undo'), + click: _this.context.createInvokeHandler('editor.undo') + }).render(); + }); + this.context.memo('button.help', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.question), + tooltip: _this.options.help, + click: _this.context.createInvokeHandler('helpDialog.show') + }).render(); + }); + }; + /** + * image : [ + * ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']], + * ['float', ['floatLeft', 'floatRight', 'floatNone' ]], + * ['remove', ['removeMedia']] + * ], + */ + Buttons.prototype.addImagePopoverButtons = function () { + var _this = this; + // Image Size Buttons + this.context.memo('button.imageSize100', function () { + return _this.button({ + contents: '<span class="note-fontsize-10">100%</span>', + tooltip: _this.lang.image.resizeFull, + click: _this.context.createInvokeHandler('editor.resize', '1') + }).render(); + }); + this.context.memo('button.imageSize50', function () { + return _this.button({ + contents: '<span class="note-fontsize-10">50%</span>', + tooltip: _this.lang.image.resizeHalf, + click: _this.context.createInvokeHandler('editor.resize', '0.5') + }).render(); + }); + this.context.memo('button.imageSize25', function () { + return _this.button({ + contents: '<span class="note-fontsize-10">25%</span>', + tooltip: _this.lang.image.resizeQuarter, + click: _this.context.createInvokeHandler('editor.resize', '0.25') + }).render(); + }); + // Float Buttons + this.context.memo('button.floatLeft', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.alignLeft), + tooltip: _this.lang.image.floatLeft, + click: _this.context.createInvokeHandler('editor.floatMe', 'left') + }).render(); + }); + this.context.memo('button.floatRight', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.alignRight), + tooltip: _this.lang.image.floatRight, + click: _this.context.createInvokeHandler('editor.floatMe', 'right') + }).render(); + }); + this.context.memo('button.floatNone', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.alignJustify), + tooltip: _this.lang.image.floatNone, + click: _this.context.createInvokeHandler('editor.floatMe', 'none') + }).render(); + }); + // Remove Buttons + this.context.memo('button.removeMedia', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.trash), + tooltip: _this.lang.image.remove, + click: _this.context.createInvokeHandler('editor.removeMedia') + }).render(); + }); + }; + Buttons.prototype.addLinkPopoverButtons = function () { + var _this = this; + this.context.memo('button.linkDialogShow', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.link), + tooltip: _this.lang.link.edit, + click: _this.context.createInvokeHandler('linkDialog.show') + }).render(); + }); + this.context.memo('button.unlink', function () { + return _this.button({ + contents: _this.ui.icon(_this.options.icons.unlink), + tooltip: _this.lang.link.unlink, + click: _this.context.createInvokeHandler('editor.unlink') + }).render(); + }); + }; + /** + * table : [ + * ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']], + * ['delete', ['deleteRow', 'deleteCol', 'deleteTable']] + * ], + */ + Buttons.prototype.addTablePopoverButtons = function () { + var _this = this; + this.context.memo('button.addRowUp', function () { + return _this.button({ + className: 'btn-md', + contents: _this.ui.icon(_this.options.icons.rowAbove), + tooltip: _this.lang.table.addRowAbove, + click: _this.context.createInvokeHandler('editor.addRow', 'top') + }).render(); + }); + this.context.memo('button.addRowDown', function () { + return _this.button({ + className: 'btn-md', + contents: _this.ui.icon(_this.options.icons.rowBelow), + tooltip: _this.lang.table.addRowBelow, + click: _this.context.createInvokeHandler('editor.addRow', 'bottom') + }).render(); + }); + this.context.memo('button.addColLeft', function () { + return _this.button({ + className: 'btn-md', + contents: _this.ui.icon(_this.options.icons.colBefore), + tooltip: _this.lang.table.addColLeft, + click: _this.context.createInvokeHandler('editor.addCol', 'left') + }).render(); + }); + this.context.memo('button.addColRight', function () { + return _this.button({ + className: 'btn-md', + contents: _this.ui.icon(_this.options.icons.colAfter), + tooltip: _this.lang.table.addColRight, + click: _this.context.createInvokeHandler('editor.addCol', 'right') + }).render(); + }); + this.context.memo('button.deleteRow', function () { + return _this.button({ + className: 'btn-md', + contents: _this.ui.icon(_this.options.icons.rowRemove), + tooltip: _this.lang.table.delRow, + click: _this.context.createInvokeHandler('editor.deleteRow') + }).render(); + }); + this.context.memo('button.deleteCol', function () { + return _this.button({ + className: 'btn-md', + contents: _this.ui.icon(_this.options.icons.colRemove), + tooltip: _this.lang.table.delCol, + click: _this.context.createInvokeHandler('editor.deleteCol') + }).render(); + }); + this.context.memo('button.deleteTable', function () { + return _this.button({ + className: 'btn-md', + contents: _this.ui.icon(_this.options.icons.trash), + tooltip: _this.lang.table.delTable, + click: _this.context.createInvokeHandler('editor.deleteTable') + }).render(); + }); + }; + Buttons.prototype.build = function ($container, groups) { + for (var groupIdx = 0, groupLen = groups.length; groupIdx < groupLen; groupIdx++) { + var group = groups[groupIdx]; + var groupName = $$1.isArray(group) ? group[0] : group; + var buttons = $$1.isArray(group) ? ((group.length === 1) ? [group[0]] : group[1]) : [group]; + var $group = this.ui.buttonGroup({ + className: 'note-' + groupName + }).render(); + for (var idx = 0, len = buttons.length; idx < len; idx++) { + var btn = this.context.memo('button.' + buttons[idx]); + if (btn) { + $group.append(typeof btn === 'function' ? btn(this.context) : btn); + } + } + $group.appendTo($container); + } + }; + /** + * @param {jQuery} [$container] + */ + Buttons.prototype.updateCurrentStyle = function ($container) { + var _this = this; + var $cont = $container || this.$toolbar; + var styleInfo = this.context.invoke('editor.currentStyle'); + this.updateBtnStates($cont, { + '.note-btn-bold': function () { + return styleInfo['font-bold'] === 'bold'; + }, + '.note-btn-italic': function () { + return styleInfo['font-italic'] === 'italic'; + }, + '.note-btn-underline': function () { + return styleInfo['font-underline'] === 'underline'; + }, + '.note-btn-subscript': function () { + return styleInfo['font-subscript'] === 'subscript'; + }, + '.note-btn-superscript': function () { + return styleInfo['font-superscript'] === 'superscript'; + }, + '.note-btn-strikethrough': function () { + return styleInfo['font-strikethrough'] === 'strikethrough'; + } + }); + if (styleInfo['font-family']) { + var fontNames = styleInfo['font-family'].split(',').map(function (name) { + return name.replace(/[\'\"]/g, '') + .replace(/\s+$/, '') + .replace(/^\s+/, ''); + }); + var fontName_1 = lists.find(fontNames, this.isFontInstalled.bind(this)); + $cont.find('.dropdown-fontname a').each(function (idx, item) { + var $item = $$1(item); + // always compare string to avoid creating another func. + var isChecked = ($item.data('value') + '') === (fontName_1 + ''); + $item.toggleClass('checked', isChecked); + }); + $cont.find('.note-current-fontname').text(fontName_1).css('font-family', fontName_1); + } + if (styleInfo['font-size']) { + var fontSize_1 = styleInfo['font-size']; + $cont.find('.dropdown-fontsize a').each(function (idx, item) { + var $item = $$1(item); + // always compare with string to avoid creating another func. + var isChecked = ($item.data('value') + '') === (fontSize_1 + ''); + $item.toggleClass('checked', isChecked); + }); + $cont.find('.note-current-fontsize').text(fontSize_1); + } + if (styleInfo['line-height']) { + var lineHeight_1 = styleInfo['line-height']; + $cont.find('.dropdown-line-height li a').each(function (idx, item) { + // always compare with string to avoid creating another func. + var isChecked = ($$1(item).data('value') + '') === (lineHeight_1 + ''); + _this.className = isChecked ? 'checked' : ''; + }); + } + }; + Buttons.prototype.updateBtnStates = function ($container, infos) { + var _this = this; + $$1.each(infos, function (selector, pred) { + _this.ui.toggleBtnActive($container.find(selector), pred()); + }); + }; + Buttons.prototype.tableMoveHandler = function (event) { + var PX_PER_EM = 18; + var $picker = $$1(event.target.parentNode); // target is mousecatcher + var $dimensionDisplay = $picker.next(); + var $catcher = $picker.find('.note-dimension-picker-mousecatcher'); + var $highlighted = $picker.find('.note-dimension-picker-highlighted'); + var $unhighlighted = $picker.find('.note-dimension-picker-unhighlighted'); + var posOffset; + // HTML5 with jQuery - e.offsetX is undefined in Firefox + if (event.offsetX === undefined) { + var posCatcher = $$1(event.target).offset(); + posOffset = { + x: event.pageX - posCatcher.left, + y: event.pageY - posCatcher.top + }; + } + else { + posOffset = { + x: event.offsetX, + y: event.offsetY + }; + } + var dim = { + c: Math.ceil(posOffset.x / PX_PER_EM) || 1, + r: Math.ceil(posOffset.y / PX_PER_EM) || 1 + }; + $highlighted.css({ width: dim.c + 'em', height: dim.r + 'em' }); + $catcher.data('value', dim.c + 'x' + dim.r); + if (dim.c > 3 && dim.c < this.options.insertTableMaxSize.col) { + $unhighlighted.css({ width: dim.c + 1 + 'em' }); + } + if (dim.r > 3 && dim.r < this.options.insertTableMaxSize.row) { + $unhighlighted.css({ height: dim.r + 1 + 'em' }); + } + $dimensionDisplay.html(dim.c + ' x ' + dim.r); + }; + return Buttons; +}()); + +var Toolbar = /** @class */ (function () { + function Toolbar(context) { + this.context = context; + this.$window = $$1(window); + this.$document = $$1(document); + this.ui = $$1.summernote.ui; + this.$note = context.layoutInfo.note; + this.$editor = context.layoutInfo.editor; + this.$toolbar = context.layoutInfo.toolbar; + this.options = context.options; + this.followScroll = this.followScroll.bind(this); + } + Toolbar.prototype.shouldInitialize = function () { + return !this.options.airMode; + }; + Toolbar.prototype.initialize = function () { + var _this = this; + this.options.toolbar = this.options.toolbar || []; + if (!this.options.toolbar.length) { + this.$toolbar.hide(); + } + else { + this.context.invoke('buttons.build', this.$toolbar, this.options.toolbar); + } + if (this.options.toolbarContainer) { + this.$toolbar.appendTo(this.options.toolbarContainer); + } + this.changeContainer(false); + this.$note.on('summernote.keyup summernote.mouseup summernote.change', function () { + _this.context.invoke('buttons.updateCurrentStyle'); + }); + this.context.invoke('buttons.updateCurrentStyle'); + if (this.options.followingToolbar) { + this.$window.on('scroll resize', this.followScroll); + } + }; + Toolbar.prototype.destroy = function () { + this.$toolbar.children().remove(); + if (this.options.followingToolbar) { + this.$window.off('scroll resize', this.followScroll); + } + }; + Toolbar.prototype.followScroll = function () { + if (this.$editor.hasClass('fullscreen')) { + return false; + } + var $toolbarWrapper = this.$toolbar.parent('.note-toolbar-wrapper'); + var editorHeight = this.$editor.outerHeight(); + var editorWidth = this.$editor.width(); + var toolbarHeight = this.$toolbar.height(); + $toolbarWrapper.css({ + height: toolbarHeight + }); + // check if the web app is currently using another static bar + var otherBarHeight = 0; + if (this.options.otherStaticBar) { + otherBarHeight = $$1(this.options.otherStaticBar).outerHeight(); + } + var currentOffset = this.$document.scrollTop(); + var editorOffsetTop = this.$editor.offset().top; + var editorOffsetBottom = editorOffsetTop + editorHeight; + var activateOffset = editorOffsetTop - otherBarHeight; + var deactivateOffsetBottom = editorOffsetBottom - otherBarHeight - toolbarHeight; + if ((currentOffset > activateOffset) && (currentOffset < deactivateOffsetBottom)) { + this.$toolbar.css({ + position: 'fixed', + top: otherBarHeight, + width: editorWidth + }); + } + else { + this.$toolbar.css({ + position: 'relative', + top: 0, + width: '100%' + }); + } + }; + Toolbar.prototype.changeContainer = function (isFullscreen) { + if (isFullscreen) { + this.$toolbar.prependTo(this.$editor); + } + else { + if (this.options.toolbarContainer) { + this.$toolbar.appendTo(this.options.toolbarContainer); + } + } + }; + Toolbar.prototype.updateFullscreen = function (isFullscreen) { + this.ui.toggleBtnActive(this.$toolbar.find('.btn-fullscreen'), isFullscreen); + this.changeContainer(isFullscreen); + }; + Toolbar.prototype.updateCodeview = function (isCodeview) { + this.ui.toggleBtnActive(this.$toolbar.find('.btn-codeview'), isCodeview); + if (isCodeview) { + this.deactivate(); + } + else { + this.activate(); + } + }; + Toolbar.prototype.activate = function (isIncludeCodeview) { + var $btn = this.$toolbar.find('button'); + if (!isIncludeCodeview) { + $btn = $btn.not('.btn-codeview'); + } + this.ui.toggleBtn($btn, true); + }; + Toolbar.prototype.deactivate = function (isIncludeCodeview) { + var $btn = this.$toolbar.find('button'); + if (!isIncludeCodeview) { + $btn = $btn.not('.btn-codeview'); + } + this.ui.toggleBtn($btn, false); + }; + return Toolbar; +}()); + +var LinkDialog = /** @class */ (function () { + function LinkDialog(context) { + this.context = context; + this.ui = $$1.summernote.ui; + this.$body = $$1(document.body); + this.$editor = context.layoutInfo.editor; + this.options = context.options; + this.lang = this.options.langInfo; + context.memo('help.linkDialog.show', this.options.langInfo.help['linkDialog.show']); + } + LinkDialog.prototype.initialize = function () { + var $container = this.options.dialogsInBody ? this.$body : this.$editor; + var body = [ + '<div class="form-group note-form-group">', + "<label class=\"note-form-label\">" + this.lang.link.textToDisplay + "</label>", + '<input class="note-link-text form-control note-form-control note-input" type="text" />', + '</div>', + '<div class="form-group note-form-group">', + "<label class=\"note-form-label\">" + this.lang.link.url + "</label>", + '<input class="note-link-url form-control note-form-control note-input" type="text" value="http://" />', + '</div>', + !this.options.disableLinkTarget + ? $$1('<div/>').append(this.ui.checkbox({ + id: 'sn-checkbox-open-in-new-window', + text: this.lang.link.openInNewWindow, + checked: true + }).render()).html() + : '' + ].join(''); + var buttonClass = 'btn btn-primary note-btn note-btn-primary note-link-btn'; + var footer = "<button type=\"submit\" href=\"#\" class=\"" + buttonClass + "\" disabled>" + this.lang.link.insert + "</button>"; + this.$dialog = this.ui.dialog({ + className: 'link-dialog', + title: this.lang.link.insert, + fade: this.options.dialogsFade, + body: body, + footer: footer + }).render().appendTo($container); + }; + LinkDialog.prototype.destroy = function () { + this.ui.hideDialog(this.$dialog); + this.$dialog.remove(); + }; + LinkDialog.prototype.bindEnterKey = function ($input, $btn) { + $input.on('keypress', function (event) { + if (event.keyCode === key.code.ENTER) { + event.preventDefault(); + $btn.trigger('click'); + } + }); + }; + /** + * toggle update button + */ + LinkDialog.prototype.toggleLinkBtn = function ($linkBtn, $linkText, $linkUrl) { + this.ui.toggleBtn($linkBtn, $linkText.val() && $linkUrl.val()); + }; + /** + * Show link dialog and set event handlers on dialog controls. + * + * @param {Object} linkInfo + * @return {Promise} + */ + LinkDialog.prototype.showLinkDialog = function (linkInfo) { + var _this = this; + return $$1.Deferred(function (deferred) { + var $linkText = _this.$dialog.find('.note-link-text'); + var $linkUrl = _this.$dialog.find('.note-link-url'); + var $linkBtn = _this.$dialog.find('.note-link-btn'); + var $openInNewWindow = _this.$dialog.find('input[type=checkbox]'); + _this.ui.onDialogShown(_this.$dialog, function () { + _this.context.triggerEvent('dialog.shown'); + // if no url was given, copy text to url + if (!linkInfo.url) { + linkInfo.url = linkInfo.text; + } + $linkText.val(linkInfo.text); + var handleLinkTextUpdate = function () { + _this.toggleLinkBtn($linkBtn, $linkText, $linkUrl); + // if linktext was modified by keyup, + // stop cloning text from linkUrl + linkInfo.text = $linkText.val(); + }; + $linkText.on('input', handleLinkTextUpdate).on('paste', function () { + setTimeout(handleLinkTextUpdate, 0); + }); + var handleLinkUrlUpdate = function () { + _this.toggleLinkBtn($linkBtn, $linkText, $linkUrl); + // display same link on `Text to display` input + // when create a new link + if (!linkInfo.text) { + $linkText.val($linkUrl.val()); + } + }; + $linkUrl.on('input', handleLinkUrlUpdate).on('paste', function () { + setTimeout(handleLinkUrlUpdate, 0); + }).val(linkInfo.url); + if (!env.isSupportTouch) { + $linkUrl.trigger('focus'); + } + _this.toggleLinkBtn($linkBtn, $linkText, $linkUrl); + _this.bindEnterKey($linkUrl, $linkBtn); + _this.bindEnterKey($linkText, $linkBtn); + var isChecked = linkInfo.isNewWindow !== undefined + ? linkInfo.isNewWindow : _this.context.options.linkTargetBlank; + $openInNewWindow.prop('checked', isChecked); + $linkBtn.one('click', function (event) { + event.preventDefault(); + deferred.resolve({ + range: linkInfo.range, + url: $linkUrl.val(), + text: $linkText.val(), + isNewWindow: $openInNewWindow.is(':checked') + }); + _this.ui.hideDialog(_this.$dialog); + }); + }); + _this.ui.onDialogHidden(_this.$dialog, function () { + // detach events + $linkText.off('input paste keypress'); + $linkUrl.off('input paste keypress'); + $linkBtn.off('click'); + if (deferred.state() === 'pending') { + deferred.reject(); + } + }); + _this.ui.showDialog(_this.$dialog); + }).promise(); + }; + /** + * @param {Object} layoutInfo + */ + LinkDialog.prototype.show = function () { + var _this = this; + var linkInfo = this.context.invoke('editor.getLinkInfo'); + this.context.invoke('editor.saveRange'); + this.showLinkDialog(linkInfo).then(function (linkInfo) { + _this.context.invoke('editor.restoreRange'); + _this.context.invoke('editor.createLink', linkInfo); + }).fail(function () { + _this.context.invoke('editor.restoreRange'); + }); + }; + return LinkDialog; +}()); + +var LinkPopover = /** @class */ (function () { + function LinkPopover(context) { + var _this = this; + this.context = context; + this.ui = $$1.summernote.ui; + this.options = context.options; + this.events = { + 'summernote.keyup summernote.mouseup summernote.change summernote.scroll': function () { + _this.update(); + }, + 'summernote.disable summernote.dialog.shown': function () { + _this.hide(); + } + }; + } + LinkPopover.prototype.shouldInitialize = function () { + return !lists.isEmpty(this.options.popover.link); + }; + LinkPopover.prototype.initialize = function () { + this.$popover = this.ui.popover({ + className: 'note-link-popover', + callback: function ($node) { + var $content = $node.find('.popover-content,.note-popover-content'); + $content.prepend('<span><a target="_blank"></a> </span>'); + } + }).render().appendTo(this.options.container); + var $content = this.$popover.find('.popover-content,.note-popover-content'); + this.context.invoke('buttons.build', $content, this.options.popover.link); + }; + LinkPopover.prototype.destroy = function () { + this.$popover.remove(); + }; + LinkPopover.prototype.update = function () { + // Prevent focusing on editable when invoke('code') is executed + if (!this.context.invoke('editor.hasFocus')) { + this.hide(); + return; + } + var rng = this.context.invoke('editor.createRange'); + if (rng.isCollapsed() && rng.isOnAnchor()) { + var anchor = dom.ancestor(rng.sc, dom.isAnchor); + var href = $$1(anchor).attr('href'); + this.$popover.find('a').attr('href', href).html(href); + var pos = dom.posFromPlaceholder(anchor); + this.$popover.css({ + display: 'block', + left: pos.left, + top: pos.top + }); + } + else { + this.hide(); + } + }; + LinkPopover.prototype.hide = function () { + this.$popover.hide(); + }; + return LinkPopover; +}()); + +var ImageDialog = /** @class */ (function () { + function ImageDialog(context) { + this.context = context; + this.ui = $$1.summernote.ui; + this.$body = $$1(document.body); + this.$editor = context.layoutInfo.editor; + this.options = context.options; + this.lang = this.options.langInfo; + } + ImageDialog.prototype.initialize = function () { + var $container = this.options.dialogsInBody ? this.$body : this.$editor; + var imageLimitation = ''; + if (this.options.maximumImageFileSize) { + var unit = Math.floor(Math.log(this.options.maximumImageFileSize) / Math.log(1024)); + var readableSize = (this.options.maximumImageFileSize / Math.pow(1024, unit)).toFixed(2) * 1 + + ' ' + ' KMGTP'[unit] + 'B'; + imageLimitation = "<small>" + (this.lang.image.maximumFileSize + ' : ' + readableSize) + "</small>"; + } + var body = [ + '<div class="form-group note-form-group note-group-select-from-files">', + '<label class="note-form-label">' + this.lang.image.selectFromFiles + '</label>', + '<input class="note-image-input note-form-control note-input" ', + ' type="file" name="files" accept="image/*" multiple="multiple" />', + imageLimitation, + '</div>', + '<div class="form-group note-group-image-url" style="overflow:auto;">', + '<label class="note-form-label">' + this.lang.image.url + '</label>', + '<input class="note-image-url form-control note-form-control note-input ', + ' col-md-12" type="text" />', + '</div>' + ].join(''); + var buttonClass = 'btn btn-primary note-btn note-btn-primary note-image-btn'; + var footer = "<button type=\"submit\" href=\"#\" class=\"" + buttonClass + "\" disabled>" + this.lang.image.insert + "</button>"; + this.$dialog = this.ui.dialog({ + title: this.lang.image.insert, + fade: this.options.dialogsFade, + body: body, + footer: footer + }).render().appendTo($container); + }; + ImageDialog.prototype.destroy = function () { + this.ui.hideDialog(this.$dialog); + this.$dialog.remove(); + }; + ImageDialog.prototype.bindEnterKey = function ($input, $btn) { + $input.on('keypress', function (event) { + if (event.keyCode === key.code.ENTER) { + event.preventDefault(); + $btn.trigger('click'); + } + }); + }; + ImageDialog.prototype.show = function () { + var _this = this; + this.context.invoke('editor.saveRange'); + this.showImageDialog().then(function (data) { + // [workaround] hide dialog before restore range for IE range focus + _this.ui.hideDialog(_this.$dialog); + _this.context.invoke('editor.restoreRange'); + if (typeof data === 'string') { + _this.context.invoke('editor.insertImage', data); + } + else { + _this.context.invoke('editor.insertImagesOrCallback', data); + } + }).fail(function () { + _this.context.invoke('editor.restoreRange'); + }); + }; + /** + * show image dialog + * + * @param {jQuery} $dialog + * @return {Promise} + */ + ImageDialog.prototype.showImageDialog = function () { + var _this = this; + return $$1.Deferred(function (deferred) { + var $imageInput = _this.$dialog.find('.note-image-input'); + var $imageUrl = _this.$dialog.find('.note-image-url'); + var $imageBtn = _this.$dialog.find('.note-image-btn'); + _this.ui.onDialogShown(_this.$dialog, function () { + _this.context.triggerEvent('dialog.shown'); + // Cloning imageInput to clear element. + $imageInput.replaceWith($imageInput.clone().on('change', function (event) { + deferred.resolve(event.target.files || event.target.value); + }).val('')); + $imageBtn.click(function (event) { + event.preventDefault(); + deferred.resolve($imageUrl.val()); + }); + $imageUrl.on('keyup paste', function () { + var url = $imageUrl.val(); + _this.ui.toggleBtn($imageBtn, url); + }).val(''); + if (!env.isSupportTouch) { + $imageUrl.trigger('focus'); + } + _this.bindEnterKey($imageUrl, $imageBtn); + }); + _this.ui.onDialogHidden(_this.$dialog, function () { + $imageInput.off('change'); + $imageUrl.off('keyup paste keypress'); + $imageBtn.off('click'); + if (deferred.state() === 'pending') { + deferred.reject(); + } + }); + _this.ui.showDialog(_this.$dialog); + }); + }; + return ImageDialog; +}()); + +/** + * Image popover module + * mouse events that show/hide popover will be handled by Handle.js. + * Handle.js will receive the events and invoke 'imagePopover.update'. + */ +var ImagePopover = /** @class */ (function () { + function ImagePopover(context) { + var _this = this; + this.context = context; + this.ui = $$1.summernote.ui; + this.editable = context.layoutInfo.editable[0]; + this.options = context.options; + this.events = { + 'summernote.disable': function () { + _this.hide(); + } + }; + } + ImagePopover.prototype.shouldInitialize = function () { + return !lists.isEmpty(this.options.popover.image); + }; + ImagePopover.prototype.initialize = function () { + this.$popover = this.ui.popover({ + className: 'note-image-popover' + }).render().appendTo(this.options.container); + var $content = this.$popover.find('.popover-content,.note-popover-content'); + this.context.invoke('buttons.build', $content, this.options.popover.image); + }; + ImagePopover.prototype.destroy = function () { + this.$popover.remove(); + }; + ImagePopover.prototype.update = function (target) { + if (dom.isImg(target)) { + var pos = dom.posFromPlaceholder(target); + var posEditor = dom.posFromPlaceholder(this.editable); + this.$popover.css({ + display: 'block', + left: this.options.popatmouse ? event.pageX - 20 : pos.left, + top: this.options.popatmouse ? event.pageY : Math.min(pos.top, posEditor.top) + }); + } + else { + this.hide(); + } + }; + ImagePopover.prototype.hide = function () { + this.$popover.hide(); + }; + return ImagePopover; +}()); + +var TablePopover = /** @class */ (function () { + function TablePopover(context) { + var _this = this; + this.context = context; + this.ui = $$1.summernote.ui; + this.options = context.options; + this.events = { + 'summernote.mousedown': function (we, e) { + _this.update(e.target); + }, + 'summernote.keyup summernote.scroll summernote.change': function () { + _this.update(); + }, + 'summernote.disable': function () { + _this.hide(); + } + }; + } + TablePopover.prototype.shouldInitialize = function () { + return !lists.isEmpty(this.options.popover.table); + }; + TablePopover.prototype.initialize = function () { + this.$popover = this.ui.popover({ + className: 'note-table-popover' + }).render().appendTo(this.options.container); + var $content = this.$popover.find('.popover-content,.note-popover-content'); + this.context.invoke('buttons.build', $content, this.options.popover.table); + // [workaround] Disable Firefox's default table editor + if (env.isFF) { + document.execCommand('enableInlineTableEditing', false, false); + } + }; + TablePopover.prototype.destroy = function () { + this.$popover.remove(); + }; + TablePopover.prototype.update = function (target) { + if (this.context.isDisabled()) { + return false; + } + var isCell = dom.isCell(target); + if (isCell) { + var pos = dom.posFromPlaceholder(target); + this.$popover.css({ + display: 'block', + left: pos.left, + top: pos.top + }); + } + else { + this.hide(); + } + return isCell; + }; + TablePopover.prototype.hide = function () { + this.$popover.hide(); + }; + return TablePopover; +}()); + +var VideoDialog = /** @class */ (function () { + function VideoDialog(context) { + this.context = context; + this.ui = $$1.summernote.ui; + this.$body = $$1(document.body); + this.$editor = context.layoutInfo.editor; + this.options = context.options; + this.lang = this.options.langInfo; + } + VideoDialog.prototype.initialize = function () { + var $container = this.options.dialogsInBody ? this.$body : this.$editor; + var body = [ + '<div class="form-group note-form-group row-fluid">', + "<label class=\"note-form-label\">" + this.lang.video.url + " <small class=\"text-muted\">" + this.lang.video.providers + "</small></label>", + '<input class="note-video-url form-control note-form-control note-input" type="text" />', + '</div>' + ].join(''); + var buttonClass = 'btn btn-primary note-btn note-btn-primary note-video-btn'; + var footer = "<button type=\"submit\" href=\"#\" class=\"" + buttonClass + "\" disabled>" + this.lang.video.insert + "</button>"; + this.$dialog = this.ui.dialog({ + title: this.lang.video.insert, + fade: this.options.dialogsFade, + body: body, + footer: footer + }).render().appendTo($container); + }; + VideoDialog.prototype.destroy = function () { + this.ui.hideDialog(this.$dialog); + this.$dialog.remove(); + }; + VideoDialog.prototype.bindEnterKey = function ($input, $btn) { + $input.on('keypress', function (event) { + if (event.keyCode === key.code.ENTER) { + event.preventDefault(); + $btn.trigger('click'); + } + }); + }; + VideoDialog.prototype.createVideoNode = function (url) { + // video url patterns(youtube, instagram, vimeo, dailymotion, youku, mp4, ogg, webm) + var ytRegExp = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/; + var ytMatch = url.match(ytRegExp); + var igRegExp = /(?:www\.|\/\/)instagram\.com\/p\/(.[a-zA-Z0-9_-]*)/; + var igMatch = url.match(igRegExp); + var vRegExp = /\/\/vine\.co\/v\/([a-zA-Z0-9]+)/; + var vMatch = url.match(vRegExp); + var vimRegExp = /\/\/(player\.)?vimeo\.com\/([a-z]*\/)*(\d+)[?]?.*/; + var vimMatch = url.match(vimRegExp); + var dmRegExp = /.+dailymotion.com\/(video|hub)\/([^_]+)[^#]*(#video=([^_&]+))?/; + var dmMatch = url.match(dmRegExp); + var youkuRegExp = /\/\/v\.youku\.com\/v_show\/id_(\w+)=*\.html/; + var youkuMatch = url.match(youkuRegExp); + var qqRegExp = /\/\/v\.qq\.com.*?vid=(.+)/; + var qqMatch = url.match(qqRegExp); + var qqRegExp2 = /\/\/v\.qq\.com\/x?\/?(page|cover).*?\/([^\/]+)\.html\??.*/; + var qqMatch2 = url.match(qqRegExp2); + var mp4RegExp = /^.+.(mp4|m4v)$/; + var mp4Match = url.match(mp4RegExp); + var oggRegExp = /^.+.(ogg|ogv)$/; + var oggMatch = url.match(oggRegExp); + var webmRegExp = /^.+.(webm)$/; + var webmMatch = url.match(webmRegExp); + var $video; + if (ytMatch && ytMatch[1].length === 11) { + var youtubeId = ytMatch[1]; + $video = $$1('<iframe>') + .attr('frameborder', 0) + .attr('src', '//www.youtube.com/embed/' + youtubeId) + .attr('width', '640').attr('height', '360'); + } + else if (igMatch && igMatch[0].length) { + $video = $$1('<iframe>') + .attr('frameborder', 0) + .attr('src', 'https://instagram.com/p/' + igMatch[1] + '/embed/') + .attr('width', '612').attr('height', '710') + .attr('scrolling', 'no') + .attr('allowtransparency', 'true'); + } + else if (vMatch && vMatch[0].length) { + $video = $$1('<iframe>') + .attr('frameborder', 0) + .attr('src', vMatch[0] + '/embed/simple') + .attr('width', '600').attr('height', '600') + .attr('class', 'vine-embed'); + } + else if (vimMatch && vimMatch[3].length) { + $video = $$1('<iframe webkitallowfullscreen mozallowfullscreen allowfullscreen>') + .attr('frameborder', 0) + .attr('src', '//player.vimeo.com/video/' + vimMatch[3]) + .attr('width', '640').attr('height', '360'); + } + else if (dmMatch && dmMatch[2].length) { + $video = $$1('<iframe>') + .attr('frameborder', 0) + .attr('src', '//www.dailymotion.com/embed/video/' + dmMatch[2]) + .attr('width', '640').attr('height', '360'); + } + else if (youkuMatch && youkuMatch[1].length) { + $video = $$1('<iframe webkitallowfullscreen mozallowfullscreen allowfullscreen>') + .attr('frameborder', 0) + .attr('height', '498') + .attr('width', '510') + .attr('src', '//player.youku.com/embed/' + youkuMatch[1]); + } + else if ((qqMatch && qqMatch[1].length) || (qqMatch2 && qqMatch2[2].length)) { + var vid = ((qqMatch && qqMatch[1].length) ? qqMatch[1] : qqMatch2[2]); + $video = $$1('<iframe webkitallowfullscreen mozallowfullscreen allowfullscreen>') + .attr('frameborder', 0) + .attr('height', '310') + .attr('width', '500') + .attr('src', 'http://v.qq.com/iframe/player.html?vid=' + vid + '&auto=0'); + } + else if (mp4Match || oggMatch || webmMatch) { + $video = $$1('<video controls>') + .attr('src', url) + .attr('width', '640').attr('height', '360'); + } + else { + // this is not a known video link. Now what, Cat? Now what? + return false; + } + $video.addClass('note-video-clip'); + return $video[0]; + }; + VideoDialog.prototype.show = function () { + var _this = this; + var text = this.context.invoke('editor.getSelectedText'); + this.context.invoke('editor.saveRange'); + this.showVideoDialog(text).then(function (url) { + // [workaround] hide dialog before restore range for IE range focus + _this.ui.hideDialog(_this.$dialog); + _this.context.invoke('editor.restoreRange'); + // build node + var $node = _this.createVideoNode(url); + if ($node) { + // insert video node + _this.context.invoke('editor.insertNode', $node); + } + }).fail(function () { + _this.context.invoke('editor.restoreRange'); + }); + }; + /** + * show image dialog + * + * @param {jQuery} $dialog + * @return {Promise} + */ + VideoDialog.prototype.showVideoDialog = function (text) { + var _this = this; + return $$1.Deferred(function (deferred) { + var $videoUrl = _this.$dialog.find('.note-video-url'); + var $videoBtn = _this.$dialog.find('.note-video-btn'); + _this.ui.onDialogShown(_this.$dialog, function () { + _this.context.triggerEvent('dialog.shown'); + $videoUrl.val(text).on('input', function () { + _this.ui.toggleBtn($videoBtn, $videoUrl.val()); + }); + if (!env.isSupportTouch) { + $videoUrl.trigger('focus'); + } + $videoBtn.click(function (event) { + event.preventDefault(); + deferred.resolve($videoUrl.val()); + }); + _this.bindEnterKey($videoUrl, $videoBtn); + }); + _this.ui.onDialogHidden(_this.$dialog, function () { + $videoUrl.off('input'); + $videoBtn.off('click'); + if (deferred.state() === 'pending') { + deferred.reject(); + } + }); + _this.ui.showDialog(_this.$dialog); + }); + }; + return VideoDialog; +}()); + +var HelpDialog = /** @class */ (function () { + function HelpDialog(context) { + this.context = context; + this.ui = $$1.summernote.ui; + this.$body = $$1(document.body); + this.$editor = context.layoutInfo.editor; + this.options = context.options; + this.lang = this.options.langInfo; + } + HelpDialog.prototype.initialize = function () { + var $container = this.options.dialogsInBody ? this.$body : this.$editor; + var body = [ + '<p class="text-center">', + '<a href="http://summernote.org/" target="_blank">Summernote 0.8.9</a> · ', + '<a href="https://github.com/summernote/summernote" target="_blank">Project</a> · ', + '<a href="https://github.com/summernote/summernote/issues" target="_blank">Issues</a>', + '</p>' + ].join(''); + this.$dialog = this.ui.dialog({ + title: this.lang.options.help, + fade: this.options.dialogsFade, + body: this.createShortcutList(), + footer: body, + callback: function ($node) { + $node.find('.modal-body,.note-modal-body').css({ + 'max-height': 300, + 'overflow': 'scroll' + }); + } + }).render().appendTo($container); + }; + HelpDialog.prototype.destroy = function () { + this.ui.hideDialog(this.$dialog); + this.$dialog.remove(); + }; + HelpDialog.prototype.createShortcutList = function () { + var _this = this; + var keyMap = this.options.keyMap[env.isMac ? 'mac' : 'pc']; + return Object.keys(keyMap).map(function (key) { + var command = keyMap[key]; + var $row = $$1('<div><div class="help-list-item"/></div>'); + $row.append($$1('<label><kbd>' + key + '</kdb></label>').css({ + 'width': 180, + 'margin-right': 10 + })).append($$1('<span/>').html(_this.context.memo('help.' + command) || command)); + return $row.html(); + }).join(''); + }; + /** + * show help dialog + * + * @return {Promise} + */ + HelpDialog.prototype.showHelpDialog = function () { + var _this = this; + return $$1.Deferred(function (deferred) { + _this.ui.onDialogShown(_this.$dialog, function () { + _this.context.triggerEvent('dialog.shown'); + deferred.resolve(); + }); + _this.ui.showDialog(_this.$dialog); + }).promise(); + }; + HelpDialog.prototype.show = function () { + var _this = this; + this.context.invoke('editor.saveRange'); + this.showHelpDialog().then(function () { + _this.context.invoke('editor.restoreRange'); + }); + }; + return HelpDialog; +}()); + +var AIR_MODE_POPOVER_X_OFFSET = 20; +var AirPopover = /** @class */ (function () { + function AirPopover(context) { + var _this = this; + this.context = context; + this.ui = $$1.summernote.ui; + this.options = context.options; + this.events = { + 'summernote.keyup summernote.mouseup summernote.scroll': function () { + _this.update(); + }, + 'summernote.disable summernote.change summernote.dialog.shown': function () { + _this.hide(); + }, + 'summernote.focusout': function (we, e) { + // [workaround] Firefox doesn't support relatedTarget on focusout + // - Ignore hide action on focus out in FF. + if (env.isFF) { + return; + } + if (!e.relatedTarget || !dom.ancestor(e.relatedTarget, func.eq(_this.$popover[0]))) { + _this.hide(); + } + } + }; + } + AirPopover.prototype.shouldInitialize = function () { + return this.options.airMode && !lists.isEmpty(this.options.popover.air); + }; + AirPopover.prototype.initialize = function () { + this.$popover = this.ui.popover({ + className: 'note-air-popover' + }).render().appendTo(this.options.container); + var $content = this.$popover.find('.popover-content'); + this.context.invoke('buttons.build', $content, this.options.popover.air); + }; + AirPopover.prototype.destroy = function () { + this.$popover.remove(); + }; + AirPopover.prototype.update = function () { + var styleInfo = this.context.invoke('editor.currentStyle'); + if (styleInfo.range && !styleInfo.range.isCollapsed()) { + var rect = lists.last(styleInfo.range.getClientRects()); + if (rect) { + var bnd = func.rect2bnd(rect); + this.$popover.css({ + display: 'block', + left: Math.max(bnd.left + bnd.width / 2, 0) - AIR_MODE_POPOVER_X_OFFSET, + top: bnd.top + bnd.height + }); + this.context.invoke('buttons.updateCurrentStyle', this.$popover); + } + } + else { + this.hide(); + } + }; + AirPopover.prototype.hide = function () { + this.$popover.hide(); + }; + return AirPopover; +}()); + +var POPOVER_DIST = 5; +var HintPopover = /** @class */ (function () { + function HintPopover(context) { + var _this = this; + this.context = context; + this.ui = $$1.summernote.ui; + this.$editable = context.layoutInfo.editable; + this.options = context.options; + this.hint = this.options.hint || []; + this.direction = this.options.hintDirection || 'bottom'; + this.hints = $$1.isArray(this.hint) ? this.hint : [this.hint]; + this.events = { + 'summernote.keyup': function (we, e) { + if (!e.isDefaultPrevented()) { + _this.handleKeyup(e); + } + }, + 'summernote.keydown': function (we, e) { + _this.handleKeydown(e); + }, + 'summernote.disable summernote.dialog.shown': function () { + _this.hide(); + } + }; + } + HintPopover.prototype.shouldInitialize = function () { + return this.hints.length > 0; + }; + HintPopover.prototype.initialize = function () { + var _this = this; + this.lastWordRange = null; + this.$popover = this.ui.popover({ + className: 'note-hint-popover', + hideArrow: true, + direction: '' + }).render().appendTo(this.options.container); + this.$popover.hide(); + this.$content = this.$popover.find('.popover-content,.note-popover-content'); + this.$content.on('click', '.note-hint-item', function (e) { + _this.$content.find('.active').removeClass('active'); + $$1(e.currentTarget).addClass('active'); + _this.replace(); + }); + }; + HintPopover.prototype.destroy = function () { + this.$popover.remove(); + }; + HintPopover.prototype.selectItem = function ($item) { + this.$content.find('.active').removeClass('active'); + $item.addClass('active'); + this.$content[0].scrollTop = $item[0].offsetTop - (this.$content.innerHeight() / 2); + }; + HintPopover.prototype.moveDown = function () { + var $current = this.$content.find('.note-hint-item.active'); + var $next = $current.next(); + if ($next.length) { + this.selectItem($next); + } + else { + var $nextGroup = $current.parent().next(); + if (!$nextGroup.length) { + $nextGroup = this.$content.find('.note-hint-group').first(); + } + this.selectItem($nextGroup.find('.note-hint-item').first()); + } + }; + HintPopover.prototype.moveUp = function () { + var $current = this.$content.find('.note-hint-item.active'); + var $prev = $current.prev(); + if ($prev.length) { + this.selectItem($prev); + } + else { + var $prevGroup = $current.parent().prev(); + if (!$prevGroup.length) { + $prevGroup = this.$content.find('.note-hint-group').last(); + } + this.selectItem($prevGroup.find('.note-hint-item').last()); + } + }; + HintPopover.prototype.replace = function () { + var $item = this.$content.find('.note-hint-item.active'); + if ($item.length) { + var node = this.nodeFromItem($item); + // XXX: consider to move codes to editor for recording redo/undo. + this.lastWordRange.insertNode(node); + range.createFromNode(node).collapse().select(); + this.lastWordRange = null; + this.hide(); + this.context.triggerEvent('change', this.$editable.html(), this.$editable[0]); + this.context.invoke('editor.focus'); + } + }; + HintPopover.prototype.nodeFromItem = function ($item) { + var hint = this.hints[$item.data('index')]; + var item = $item.data('item'); + var node = hint.content ? hint.content(item) : item; + if (typeof node === 'string') { + node = dom.createText(node); + } + return node; + }; + HintPopover.prototype.createItemTemplates = function (hintIdx, items) { + var hint = this.hints[hintIdx]; + return items.map(function (item, idx) { + var $item = $$1('<div class="note-hint-item"/>'); + $item.append(hint.template ? hint.template(item) : item + ''); + $item.data({ + 'index': hintIdx, + 'item': item + }); + return $item; + }); + }; + HintPopover.prototype.handleKeydown = function (e) { + if (!this.$popover.is(':visible')) { + return; + } + if (e.keyCode === key.code.ENTER) { + e.preventDefault(); + this.replace(); + } + else if (e.keyCode === key.code.UP) { + e.preventDefault(); + this.moveUp(); + } + else if (e.keyCode === key.code.DOWN) { + e.preventDefault(); + this.moveDown(); + } + }; + HintPopover.prototype.searchKeyword = function (index, keyword, callback) { + var hint = this.hints[index]; + if (hint && hint.match.test(keyword) && hint.search) { + var matches = hint.match.exec(keyword); + hint.search(matches[1], callback); + } + else { + callback(); + } + }; + HintPopover.prototype.createGroup = function (idx, keyword) { + var _this = this; + var $group = $$1('<div class="note-hint-group note-hint-group-' + idx + '"/>'); + this.searchKeyword(idx, keyword, function (items) { + items = items || []; + if (items.length) { + $group.html(_this.createItemTemplates(idx, items)); + _this.show(); + } + }); + return $group; + }; + HintPopover.prototype.handleKeyup = function (e) { + var _this = this; + if (!lists.contains([key.code.ENTER, key.code.UP, key.code.DOWN], e.keyCode)) { + var wordRange = this.context.invoke('editor.createRange').getWordRange(); + var keyword_1 = wordRange.toString(); + if (this.hints.length && keyword_1) { + this.$content.empty(); + var bnd = func.rect2bnd(lists.last(wordRange.getClientRects())); + if (bnd) { + this.$popover.hide(); + this.lastWordRange = wordRange; + this.hints.forEach(function (hint, idx) { + if (hint.match.test(keyword_1)) { + _this.createGroup(idx, keyword_1).appendTo(_this.$content); + } + }); + // select first .note-hint-item + this.$content.find('.note-hint-item:first').addClass('active'); + // set position for popover after group is created + if (this.direction === 'top') { + this.$popover.css({ + left: bnd.left, + top: bnd.top - this.$popover.outerHeight() - POPOVER_DIST + }); + } + else { + this.$popover.css({ + left: bnd.left, + top: bnd.top + bnd.height + POPOVER_DIST + }); + } + } + } + else { + this.hide(); + } + } + }; + HintPopover.prototype.show = function () { + this.$popover.show(); + }; + HintPopover.prototype.hide = function () { + this.$popover.hide(); + }; + return HintPopover; +}()); + +var Context = /** @class */ (function () { + /** + * @param {jQuery} $note + * @param {Object} options + */ + function Context($note, options) { + this.ui = $$1.summernote.ui; + this.$note = $note; + this.memos = {}; + this.modules = {}; + this.layoutInfo = {}; + this.options = options; + this.initialize(); + } + /** + * create layout and initialize modules and other resources + */ + Context.prototype.initialize = function () { + this.layoutInfo = this.ui.createLayout(this.$note, this.options); + this._initialize(); + this.$note.hide(); + return this; + }; + /** + * destroy modules and other resources and remove layout + */ + Context.prototype.destroy = function () { + this._destroy(); + this.$note.removeData('summernote'); + this.ui.removeLayout(this.$note, this.layoutInfo); + }; + /** + * destory modules and other resources and initialize it again + */ + Context.prototype.reset = function () { + var disabled = this.isDisabled(); + this.code(dom.emptyPara); + this._destroy(); + this._initialize(); + if (disabled) { + this.disable(); + } + }; + Context.prototype._initialize = function () { + var _this = this; + // add optional buttons + var buttons = $$1.extend({}, this.options.buttons); + Object.keys(buttons).forEach(function (key) { + _this.memo('button.' + key, buttons[key]); + }); + var modules = $$1.extend({}, this.options.modules, $$1.summernote.plugins || {}); + // add and initialize modules + Object.keys(modules).forEach(function (key) { + _this.module(key, modules[key], true); + }); + Object.keys(this.modules).forEach(function (key) { + _this.initializeModule(key); + }); + }; + Context.prototype._destroy = function () { + var _this = this; + // destroy modules with reversed order + Object.keys(this.modules).reverse().forEach(function (key) { + _this.removeModule(key); + }); + Object.keys(this.memos).forEach(function (key) { + _this.removeMemo(key); + }); + // trigger custom onDestroy callback + this.triggerEvent('destroy', this); + }; + Context.prototype.code = function (html) { + var isActivated = this.invoke('codeview.isActivated'); + if (html === undefined) { + this.invoke('codeview.sync'); + return isActivated ? this.layoutInfo.codable.val() : this.layoutInfo.editable.html(); + } + else { + if (isActivated) { + this.layoutInfo.codable.val(html); + } + else { + this.layoutInfo.editable.html(html); + } + this.$note.val(html); + this.triggerEvent('change', html); + } + }; + Context.prototype.isDisabled = function () { + return this.layoutInfo.editable.attr('contenteditable') === 'false'; + }; + Context.prototype.enable = function () { + this.layoutInfo.editable.attr('contenteditable', true); + this.invoke('toolbar.activate', true); + this.triggerEvent('disable', false); + }; + Context.prototype.disable = function () { + // close codeview if codeview is opend + if (this.invoke('codeview.isActivated')) { + this.invoke('codeview.deactivate'); + } + this.layoutInfo.editable.attr('contenteditable', false); + this.invoke('toolbar.deactivate', true); + this.triggerEvent('disable', true); + }; + Context.prototype.triggerEvent = function () { + var namespace = lists.head(arguments); + var args = lists.tail(lists.from(arguments)); + var callback = this.options.callbacks[func.namespaceToCamel(namespace, 'on')]; + if (callback) { + callback.apply(this.$note[0], args); + } + this.$note.trigger('summernote.' + namespace, args); + }; + Context.prototype.initializeModule = function (key) { + var module = this.modules[key]; + module.shouldInitialize = module.shouldInitialize || func.ok; + if (!module.shouldInitialize()) { + return; + } + // initialize module + if (module.initialize) { + module.initialize(); + } + // attach events + if (module.events) { + dom.attachEvents(this.$note, module.events); + } + }; + Context.prototype.module = function (key, ModuleClass, withoutIntialize) { + if (arguments.length === 1) { + return this.modules[key]; + } + this.modules[key] = new ModuleClass(this); + if (!withoutIntialize) { + this.initializeModule(key); + } + }; + Context.prototype.removeModule = function (key) { + var module = this.modules[key]; + if (module.shouldInitialize()) { + if (module.events) { + dom.detachEvents(this.$note, module.events); + } + if (module.destroy) { + module.destroy(); + } + } + delete this.modules[key]; + }; + Context.prototype.memo = function (key, obj) { + if (arguments.length === 1) { + return this.memos[key]; + } + this.memos[key] = obj; + }; + Context.prototype.removeMemo = function (key) { + if (this.memos[key] && this.memos[key].destroy) { + this.memos[key].destroy(); + } + delete this.memos[key]; + }; + /** + * Some buttons need to change their visual style immediately once they get pressed + */ + Context.prototype.createInvokeHandlerAndUpdateState = function (namespace, value) { + var _this = this; + return function (event) { + _this.createInvokeHandler(namespace, value)(event); + _this.invoke('buttons.updateCurrentStyle'); + }; + }; + Context.prototype.createInvokeHandler = function (namespace, value) { + var _this = this; + return function (event) { + event.preventDefault(); + var $target = $$1(event.target); + _this.invoke(namespace, value || $target.closest('[data-value]').data('value'), $target); + }; + }; + Context.prototype.invoke = function () { + var namespace = lists.head(arguments); + var args = lists.tail(lists.from(arguments)); + var splits = namespace.split('.'); + var hasSeparator = splits.length > 1; + var moduleName = hasSeparator && lists.head(splits); + var methodName = hasSeparator ? lists.last(splits) : lists.head(splits); + var module = this.modules[moduleName || 'editor']; + if (!moduleName && this[methodName]) { + return this[methodName].apply(this, args); + } + else if (module && module[methodName] && module.shouldInitialize()) { + return module[methodName].apply(module, args); + } + }; + return Context; +}()); + +$$1.fn.extend({ + /** + * Summernote API + * + * @param {Object|String} + * @return {this} + */ + summernote: function () { + var type = $$1.type(lists.head(arguments)); + var isExternalAPICalled = type === 'string'; + var hasInitOptions = type === 'object'; + var options = $$1.extend({}, $$1.summernote.options, hasInitOptions ? lists.head(arguments) : {}); + // Update options + options.langInfo = $$1.extend(true, {}, $$1.summernote.lang['en-US'], $$1.summernote.lang[options.lang]); + options.icons = $$1.extend(true, {}, $$1.summernote.options.icons, options.icons); + options.tooltip = options.tooltip === 'auto' ? !env.isSupportTouch : options.tooltip; + this.each(function (idx, note) { + var $note = $$1(note); + if (!$note.data('summernote')) { + var context = new Context($note, options); + $note.data('summernote', context); + $note.data('summernote').triggerEvent('init', context.layoutInfo); + } + }); + var $note = this.first(); + if ($note.length) { + var context = $note.data('summernote'); + if (isExternalAPICalled) { + return context.invoke.apply(context, lists.from(arguments)); + } + else if (options.focus) { + context.invoke('editor.focus'); + } + } + return this; + } +}); + +$$1.summernote = $$1.extend($$1.summernote, { + version: '0.8.9', + ui: ui, + dom: dom, + range: range, + + plugins: {}, + + options: { + modules: { + 'editor': Editor, + 'clipboard': Clipboard, + 'dropzone': Dropzone, + 'codeview': CodeView, + 'statusbar': Statusbar, + 'fullscreen': Fullscreen, + 'handle': Handle, + // FIXME: HintPopover must be front of autolink + // - Script error about range when Enter key is pressed on hint popover + 'hintPopover': HintPopover, + 'autoLink': AutoLink, + 'autoSync': AutoSync, + 'placeholder': Placeholder, + 'buttons': Buttons, + 'toolbar': Toolbar, + 'linkDialog': LinkDialog, + 'linkPopover': LinkPopover, + 'imageDialog': ImageDialog, + 'imagePopover': ImagePopover, + 'tablePopover': TablePopover, + 'videoDialog': VideoDialog, + 'helpDialog': HelpDialog, + 'airPopover': AirPopover + }, + buttons: {}, + lang: 'en-US', + followingToolbar: true, + otherStaticBar: '', + // toolbar + toolbar: [ + ['style', ['style']], + ['font', ['bold', 'underline', 'clear']], + ['fontname', ['fontname']], + ['color', ['color']], + ['para', ['ul', 'ol', 'paragraph']], + ['table', ['table']], + ['insert', ['link', 'picture', 'video']], + ['view', ['fullscreen', 'codeview', 'help']] + ], + // popover + popatmouse: true, + popover: { + image: [ + ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']], + ['float', ['floatLeft', 'floatRight', 'floatNone']], + ['remove', ['removeMedia']] + ], + link: [ + ['link', ['linkDialogShow', 'unlink']] + ], + table: [ + ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']], + ['delete', ['deleteRow', 'deleteCol', 'deleteTable']] + ], + air: [ + ['color', ['color']], + ['font', ['bold', 'underline', 'clear']], + ['para', ['ul', 'paragraph']], + ['table', ['table']], + ['insert', ['link', 'picture']] + ] + }, + // air mode: inline editor + airMode: false, + width: null, + height: null, + linkTargetBlank: true, + focus: false, + tabSize: 4, + styleWithSpan: true, + shortcuts: true, + textareaAutoSync: true, + hintDirection: 'bottom', + tooltip: 'auto', + container: 'body', + maxTextLength: 0, + styleTags: [ + 'p', + { title: 'Blockquote', tag: 'blockquote', className: 'blockquote', value: 'blockquote' }, + 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' + ], + fontNames: [ + 'Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', + 'Helvetica Neue', 'Helvetica', 'Impact', 'Lucida Grande', + 'Tahoma', 'Times New Roman', 'Verdana' + ], + fontSizes: ['8', '9', '10', '11', '12', '14', '18', '24', '36'], + // pallete colors(n x n) + colors: [ + ['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'], + ['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'], + ['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'], + ['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'], + ['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'], + ['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'], + ['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'], + ['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031'] + ], + lineHeights: ['1.0', '1.2', '1.4', '1.5', '1.6', '1.8', '2.0', '3.0'], + tableClassName: 'table table-bordered', + insertTableMaxSize: { + col: 10, + row: 10 + }, + dialogsInBody: false, + dialogsFade: false, + maximumImageFileSize: null, + callbacks: { + onInit: null, + onFocus: null, + onBlur: null, + onBlurCodeview: null, + onEnter: null, + onKeyup: null, + onKeydown: null, + onImageUpload: null, + onImageUploadError: null + }, + codemirror: { + mode: 'text/html', + htmlMode: true, + lineNumbers: true + }, + keyMap: { + pc: { + 'ENTER': 'insertParagraph', + 'CTRL+Z': 'undo', + 'CTRL+Y': 'redo', + 'TAB': 'tab', + 'SHIFT+TAB': 'untab', + 'CTRL+B': 'bold', + 'CTRL+I': 'italic', + 'CTRL+U': 'underline', + 'CTRL+SHIFT+S': 'strikethrough', + 'CTRL+BACKSLASH': 'removeFormat', + 'CTRL+SHIFT+L': 'justifyLeft', + 'CTRL+SHIFT+E': 'justifyCenter', + 'CTRL+SHIFT+R': 'justifyRight', + 'CTRL+SHIFT+J': 'justifyFull', + 'CTRL+SHIFT+NUM7': 'insertUnorderedList', + 'CTRL+SHIFT+NUM8': 'insertOrderedList', + 'CTRL+LEFTBRACKET': 'outdent', + 'CTRL+RIGHTBRACKET': 'indent', + 'CTRL+NUM0': 'formatPara', + 'CTRL+NUM1': 'formatH1', + 'CTRL+NUM2': 'formatH2', + 'CTRL+NUM3': 'formatH3', + 'CTRL+NUM4': 'formatH4', + 'CTRL+NUM5': 'formatH5', + 'CTRL+NUM6': 'formatH6', + 'CTRL+ENTER': 'insertHorizontalRule', + 'CTRL+K': 'linkDialog.show' + }, + mac: { + 'ENTER': 'insertParagraph', + 'CMD+Z': 'undo', + 'CMD+SHIFT+Z': 'redo', + 'TAB': 'tab', + 'SHIFT+TAB': 'untab', + 'CMD+B': 'bold', + 'CMD+I': 'italic', + 'CMD+U': 'underline', + 'CMD+SHIFT+S': 'strikethrough', + 'CMD+BACKSLASH': 'removeFormat', + 'CMD+SHIFT+L': 'justifyLeft', + 'CMD+SHIFT+E': 'justifyCenter', + 'CMD+SHIFT+R': 'justifyRight', + 'CMD+SHIFT+J': 'justifyFull', + 'CMD+SHIFT+NUM7': 'insertUnorderedList', + 'CMD+SHIFT+NUM8': 'insertOrderedList', + 'CMD+LEFTBRACKET': 'outdent', + 'CMD+RIGHTBRACKET': 'indent', + 'CMD+NUM0': 'formatPara', + 'CMD+NUM1': 'formatH1', + 'CMD+NUM2': 'formatH2', + 'CMD+NUM3': 'formatH3', + 'CMD+NUM4': 'formatH4', + 'CMD+NUM5': 'formatH5', + 'CMD+NUM6': 'formatH6', + 'CMD+ENTER': 'insertHorizontalRule', + 'CMD+K': 'linkDialog.show' + } + }, + icons: { + 'align': 'note-icon-align', + 'alignCenter': 'note-icon-align-center', + 'alignJustify': 'note-icon-align-justify', + 'alignLeft': 'note-icon-align-left', + 'alignRight': 'note-icon-align-right', + 'rowBelow': 'note-icon-row-below', + 'colBefore': 'note-icon-col-before', + 'colAfter': 'note-icon-col-after', + 'rowAbove': 'note-icon-row-above', + 'rowRemove': 'note-icon-row-remove', + 'colRemove': 'note-icon-col-remove', + 'indent': 'note-icon-align-indent', + 'outdent': 'note-icon-align-outdent', + 'arrowsAlt': 'note-icon-arrows-alt', + 'bold': 'note-icon-bold', + 'caret': 'note-icon-caret', + 'circle': 'note-icon-circle', + 'close': 'note-icon-close', + 'code': 'note-icon-code', + 'eraser': 'note-icon-eraser', + 'font': 'note-icon-font', + 'frame': 'note-icon-frame', + 'italic': 'note-icon-italic', + 'link': 'note-icon-link', + 'unlink': 'note-icon-chain-broken', + 'magic': 'note-icon-magic', + 'menuCheck': 'note-icon-menu-check', + 'minus': 'note-icon-minus', + 'orderedlist': 'note-icon-orderedlist', + 'pencil': 'note-icon-pencil', + 'picture': 'note-icon-picture', + 'question': 'note-icon-question', + 'redo': 'note-icon-redo', + 'square': 'note-icon-square', + 'strikethrough': 'note-icon-strikethrough', + 'subscript': 'note-icon-subscript', + 'superscript': 'note-icon-superscript', + 'table': 'note-icon-table', + 'textHeight': 'note-icon-text-height', + 'trash': 'note-icon-trash', + 'underline': 'note-icon-underline', + 'undo': 'note-icon-undo', + 'unorderedlist': 'note-icon-unorderedlist', + 'video': 'note-icon-video' + } + } +}); + +}))); +//# sourceMappingURL=summernote-bs4.js.map diff --git a/addons/web_editor/static/src/js/editor/transcoder.js b/addons/web_editor/static/src/js/backend/convert_inline.js index 85903f6b4eb..3b09bef5f31 100644 --- a/addons/web_editor/static/src/js/editor/transcoder.js +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -1,9 +1,8 @@ -odoo.define('web_editor.transcoder', function (require) { +odoo.define('web_editor.convertInline', function (require) { 'use strict'; -var base = require('web_editor.base'); - -var rulesCache = []; +var fonts = require('wysiwyg.fonts'); +var FieldHtml = require('web_editor.field.html'); /** * Returns the css rules which applies on an element, tweaked so that they are @@ -14,8 +13,11 @@ var rulesCache = []; */ function getMatchedCSSRules(a) { var i, r, k; + var doc = a.ownerDocument; + var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []); + if (!rulesCache.length) { - var sheets = document.styleSheets; + var sheets = doc.styleSheets; for (i = sheets.length-1 ; i >= 0 ; i--) { var rules; // try...catch because browser may not able to enumerate rules for cross-domain sheets @@ -190,8 +192,8 @@ function fontToImg($editable) { $editable.find('.fa').each(function () { var $font = $(this); var icon, content; - _.find(base.fontIcons, function (font) { - return _.find(base.getCssSelectors(font.parser), function (data) { + _.find(fonts.fontIcons, function (font) { + return _.find(fonts.getCssSelectors(font.parser), function (data) { if ($font.is(data.selector.replace(/::?before/g, ''))) { icon = data.names[0].split('-').shift(); content = data.css.match(/content:\s*['"]?(.)['"]?/)[1]; @@ -265,9 +267,6 @@ function applyOverDescendants(node, func) { * @param {jQuery} $editable */ function classToStyle($editable) { - if (!rulesCache.length) { - getMatchedCSSRules($editable[0]); - } applyOverDescendants($editable[0], function (node) { var $target = $(node); var css = getMatchedCSSRules(node); @@ -328,9 +327,7 @@ function styleToClass($editable) { $(this).after($('a,img', this)); }).remove(); - getMatchedCSSRules($editable[0]); - - var $c = $('<span/>').appendTo(document.body); + var $c = $('<span/>').appendTo($editable[0].ownerDocument.body); applyOverDescendants($editable[0], function (node) { var $target = $(node); @@ -342,7 +339,7 @@ function styleToClass($editable) { } }); css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); - style = $target.attr('style') || ''; + style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';'); _.each(css, function (v) { style = style.replace(v, ''); }); @@ -364,13 +361,13 @@ function styleToClass($editable) { * @param {jQuery} $editable */ function attachmentThumbnailToLinkImg($editable) { - $editable.find('a[href*="/web/content/"][data-mimetype]:empty').each(function () { + $editable.find('a[href*="/web/content/"][data-mimetype]').filter(':empty, :containsExact( )').each(function () { var $link = $(this); var $img = $('<img/>') .attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, '')) .css('height', Math.max(1, $link.height()) + 'px') .css('width', Math.max(1, $link.width()) + 'px'); - $link.append($img); + $link.prepend($img); }); } @@ -384,6 +381,86 @@ function linkImgToAttachmentThumbnail($editable) { $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove(); } + +//-------------------------------------------------------------------------- +//-------------------------------------------------------------------------- + + +FieldHtml.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + commitChanges: function () { + if (!this.wysiwyg) { + return this._super(); + } + if (this.nodeOptions['style-inline']) { + this._toInline(); + } + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Converts CSS dependencies to CSS-independent HTML. + * - CSS display for attachment link -> real image + * - Font icons -> images + * - CSS styles -> inline styles + * + * @private + */ + _toInline: function () { + var $editable = this.wysiwyg.getEditable(); + var html = this.wysiwyg.getValue(); + $editable.html(html); + + attachmentThumbnailToLinkImg($editable); + fontToImg($editable); + classToStyle($editable); + this.wysiwyg.setValue($editable.html(), { + notifyChange: false, + }); + }, + /** + * Revert _toInline changes. + * + * @private + */ + _fromInline: function () { + var $editable = this.wysiwyg.getEditable(); + var html = this.wysiwyg.getValue(); + $editable.html(html); + + styleToClass($editable); + imgToFont($editable); + linkImgToAttachmentThumbnail($editable); + this.wysiwyg.setValue($editable.html(), { + notifyChange: false, + }); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onLoadWysiwyg: function () { + if (this.nodeOptions['style-inline']) { + this._fromInline(); + } + this._super(); + }, +}); + return { fontToImg: fontToImg, imgToFont: imgToFont, diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js new file mode 100644 index 00000000000..69e32fb815e --- /dev/null +++ b/addons/web_editor/static/src/js/backend/field_html.js @@ -0,0 +1,476 @@ +odoo.define('web_editor.field.html', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var basic_fields = require('web.basic_fields'); +var core = require('web.core'); +var Wysiwyg = require('web_editor.wysiwyg'); +var field_registry = require('web.field_registry'); + +var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin; + +var QWeb = core.qweb; + +/** + * FieldHtml Widget + * Intended to display HTML content. This widget uses the wysiwyg editor + * improved by odoo. + * + * nodeOptions: + * - style-inline => convert class to inline style (no re-edition) => for sending by email + * - no-attachment + * - cssEdit + * - cssReadonly + * - snippets + * - wrapper + */ +var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { + className: 'oe_form_field oe_form_field_html', + supportedFieldTypes: ['html'], + + custom_events: { + wysiwyg_focus: '_onWysiwygFocus', + wysiwyg_blur: '_onWysiwygBlur', + wysiwyg_change: '_onChange', + wysiwyg_attachment: '_onAttachmentChange', + }, + + /** + * @override + */ + willStart: function () { + this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml'); + var defAsset = this.nodeOptions.cssReadonly && ajax.loadAsset(this.nodeOptions.cssReadonly); + return $.when(this._super().then(Wysiwyg.prepare.bind(Wysiwyg, this)), defAsset); + }, + /** + * @override + */ + destroy: function () { + delete window.top[this._onUpdateIframeId]; + if (this.$iframe) { + this.$iframe.remove(); + } + this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + activate: function (options) { + if (this.wysiwyg) { + this.wysiwyg.focus(); + return true; + } + }, + /** + * Wysiwyg doesn't notify for changes done in code mode. We override + * commitChanges to manually switch back to normal mode before committing + * changes, so that the widget is aware of the changes done in code mode. + * + * @override + */ + commitChanges: function () { + var self = this; + if (!this.wysiwyg) { + return this._super(); + } + var _super = this._super.bind(this); + return this.wysiwyg.save().then(function (isDirty, html) { + self._isDirty = isDirty; + _super(); + }); + }, + /** + * @override + */ + isSet: function () { + return this.value && this.value !== "<p><br/></p>" && this.value.match(/\S/); + }, + /** + * @override + */ + getFocusableElement: function () { + return this.$target || $(); + }, + /** + * Do not re-render this field if it was the origin of the onchange call. + * + * @override + */ + reset: function (record, event) { + this._reset(record, event); + var value = this.value; + if (this.nodeOptions.wrapper) { + value = this._wrap(value); + } + value = this._textToHtml(value); + if (!event || event.target !== this) { + if (this.mode === 'edit') { + this.wysiwyg.setValue(value); + } else { + this.$content.html(value); + } + } + return $.when(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getValue: function () { + var value = this.$target.val(); + if (this.nodeOptions.wrapper) { + return this._unWrap(value); + } + return value; + }, + /** + * Create the wysiwyg instance with the target (this.$target) + * then add the editable content (this.$content). + * + * @private + * @returns {$.Promise} + */ + _createWysiwygIntance: function () { + var self = this; + this.wysiwyg = new Wysiwyg(this, this._getWysiwygOptions()); + + // by default this is synchronous because the assets are already loaded in willStart + // but it can be async in the case of options such as iframe, snippets... + return this.wysiwyg.attachTo(this.$target).then(function () { + self.$content = self.wysiwyg.$el; + self._onLoadWysiwyg(); + }); + }, + /** + * Get wysiwyg options to create wysiwyg instance. + * + * @private + * @returns {Object} + */ + _getWysiwygOptions: function () { + return { + recordInfo: { + context: this.record.getContext(this.recordParams), + res_model: this.model, + res_id: this.res_id, + }, + noAttachment: this.nodeOptions['no-attachment'], + inIframe: !!this.nodeOptions.cssEdit, + iframeCssAssets: this.nodeOptions.cssEdit, + snippets: this.nodeOptions.snippets, + + tabSize: 0, + keyMap: { + pc: { + 'TAB': null, + 'SHIFT+TAB': null, + }, + mac: { + 'TAB': null, + 'SHIFT+TAB': null, + }, + }, + generateOptions: function (options) { + var para = _.find(options.toolbar, function (item) { + return item[0] === 'para'; + }); + para[1].splice(2, 0, 'checklist'); + return options; + }, + }; + }, + /** + * trigger_up 'field_changed' add record into the "ir.attachment" field found in the view. + * This method is called when an image is uploaded via the media dialog. + * + * For e.g. when sending email, this allows people to add attachments with the content + * editor interface and that they appear in the attachment list. + * The new documents being attached to the email, they will not be erased by the CRON + * when closing the wizard. + * + * @private + * @param {Object} attachments + */ + _onAttachmentChange: function (attachments) { + if (!this.fieldNameAttachment) { + return; + } + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: _.object([this.fieldNameAttachment], [{ + operation: 'ADD_M2M', + ids: attachments + }]) + }); + }, + /** + * @override + */ + _renderEdit: function () { + var value = this._textToHtml(this.value); + if (this.nodeOptions.wrapper) { + value = this._wrap(value); + } + this.$target = $('<textarea>').val(value).hide(); + this.$target.appendTo(this.$el); + + var fieldNameAttachment = _.chain(this.recordData) + .pairs() + .find(function (value) { + return _.isObject(value[1]) && value[1].model === "ir.attachment"; + }) + .first() + .value(); + if (fieldNameAttachment) { + this.fieldNameAttachment = fieldNameAttachment; + } + + if (this.nodeOptions.cssEdit) { + // must be async because the target must be append in the DOM + this._createWysiwygIntance(); + } else { + return this._createWysiwygIntance(); + } + }, + /** + * @override + */ + _renderReadonly: function () { + var self = this; + var value = this._textToHtml(this.value); + if (this.nodeOptions.wrapper) { + value = this._wrap(value); + } + + this.$el.empty(); + + var def = $.Deferred(); + if (this.nodeOptions.cssReadonly) { + this.$iframe = $('<iframe class="o_readonly"/>'); + this.$iframe.appendTo(this.$el); + + var avoidDoubleLoad = 0; // this bug only appears on some computers with some chrome version. + + // inject content in iframe + + this.$iframe.data('load-def', def); // for unit test + window.top[this._onUpdateIframeId] = function (_avoidDoubleLoad) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg iframe double load detected'); + return; + } + self.$content = $('#iframe_target', self.$iframe[0].contentWindow.document.body); + def.resolve(); + }; + + this.$iframe.one('load', function onLoad() { + var _avoidDoubleLoad = ++avoidDoubleLoad; + ajax.loadAsset(self.nodeOptions.cssReadonly).then(function (asset) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg immediate iframe double load detected'); + return; + } + var cwindow = self.$iframe[0].contentWindow; + cwindow.document + .open("text/html", "replace") + .write( + '<head>' + + '<meta charset="utf-8"/>' + + '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' + + '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' + + _.map(asset.cssLibs, function (cssLib) { + return '<link type="text/css" rel="stylesheet" href="' + cssLib + '"/>'; + }).join('\n') + '\n' + + _.map(asset.cssContents, function (cssContent) { + return '<style type="text/css">' + cssContent + '</style>'; + }).join('\n') + '\n' + + '</head>\n' + + '<body class="o_in_iframe o_readonly">\n' + + '<div id="iframe_target">' + value + '</div>\n' + + '<script type="text/javascript">' + + 'if (window.top.' + self._onUpdateIframeId + ') {' + + 'window.top.' + self._onUpdateIframeId + '(' + _avoidDoubleLoad + ')' + + '}' + + '</script>\n' + + '</body>'); + + var height = cwindow.document.body.scrollHeight; + self.$iframe.css('height', Math.max(30, Math.min(height, 500)) + 'px'); + }); + }); + } else { + this.$content = $('<div class="o_readonly"/>').html(value); + this.$content.appendTo(this.$el); + def.resolve(); + } + + def.then(function () { + self.$content.on('click', 'ul.o_checklist > li', self._onReadonlyClickChecklist.bind(self)); + }); + }, + /** + * @private + * @param {string} text + * @returns {string} the text converted to html + */ + _textToHtml: function (text) { + var value = text || ""; + if (/%\send/.test(value)) { // is jinja + return value; + } + try { + $(text)[0].innerHTML; // crashes if text isn't html + } catch (e) { + if (value.match(/^\s*$/)) { + value = '<p><br/></p>'; + } else { + value = "<p>" + value.split(/<br\/?>/).join("<br/></p><p>") + "</p>"; + value = value + .replace(/<p><\/p>/g, '') + .replace('<p><p>', '<p>') + .replace('<p><p ', '<p ') + .replace('</p></p>', '</p>'); + } + } + return value; + }, + /** + * Move HTML contents out of their wrapper. + * + * @private + * @param {string} html content + * @returns {string} html content + */ + _unWrap: function (html) { + var $wrapper = $(html).find('#wrapper'); + return $wrapper.length ? $wrapper.html() : html; + }, + /** + * Wrap HTML in order to create a custom display. + * + * The wrapper (this.nodeOptions.wrapper) must be a static + * XML template with content id="wrapper". + * + * @private + * @param {string} html content + * @returns {string} html content + */ + _wrap: function (html) { + return $(QWeb.render(this.nodeOptions.wrapper)) + .find('#wrapper').html(html) + .end().prop('outerHTML'); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * Method called when wysiwyg triggers a change. + * + * @private + * @param {OdooEvent} ev + */ + _onChange: function (ev) { + this._doDebouncedAction.apply(this, arguments); + + var $lis = this.$content.find('.note-editable ul.o_checklist > li:not(:has(> ul.o_checklist))'); + if (!$lis.length) { + return; + } + var max = 0; + var ids = []; + $lis.map(function () { + var checklistId = parseInt(($(this).attr('id') || '0').replace(/^checklist-id-/, '')); + if (ids.indexOf(checklistId) === -1) { + if (checklistId > max) { + max = checklistId; + } + ids.push(checklistId); + } else { + $(this).removeAttr('id'); + } + }); + $lis.not('[id]').each(function () { + $(this).attr('id', 'checklist-id-' + (++max)); + }); + }, + /** + * Method called when wysiwyg triggers a change. + * + * @private + * @param {OdooEvent} ev + */ + _onReadonlyClickChecklist: function (ev) { + var self = this; + if (ev.offsetX > 0) { + return; + } + var checked = $(ev.target).hasClass('o_checked'); + var checklistId = parseInt(($(ev.target).attr('id') || '0').replace(/^checklist-id-/, '')); + + this._rpc({ + route: '/web_editor/checklist', + params: { + res_model: this.model, + res_id: this.res_id, + filename: this.name, + checklistId: checklistId, + checked: !checked, + }, + }).then(function (value) { + self._setValue(value); + }); + }, + /** + * Method called when the wysiwyg instance is loaded. + * + * @private + */ + _onLoadWysiwyg: function () { + var $button = this._renderTranslateButton(); + $button.css({ + 'font-size': '15px', + position: 'absolute', + right: '+5px', + }); + var $toolbar = this.$content.closest('.note-editor').find('.note-toolbar'); + $toolbar.css('position', 'relative'); + $toolbar.append($button); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onWysiwygBlur: function (ev) { + ev.stopPropagation(); + this._doAction(); + if (ev.data.key === 'TAB') { + this.trigger_up('navigation_move', { + direction: ev.data.shiftKey ? 'left' : 'right', + }); + } + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onWysiwygFocus: function (ev) {}, +}); + + +field_registry.add('html', FieldHtml); + + +return FieldHtml; +}); diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js deleted file mode 100644 index 7742f49bcde..00000000000 --- a/addons/web_editor/static/src/js/backend/fields.js +++ /dev/null @@ -1,532 +0,0 @@ -odoo.define('web_editor.backend', function (require) { -'use strict'; - -var AbstractField = require('web.AbstractField'); -var basic_fields = require('web.basic_fields'); -var config = require('web.config'); -var core = require('web.core'); -var session = require('web.session'); -var field_registry = require('web.field_registry'); -var SummernoteManager = require('web_editor.rte.summernote'); -var transcoder = require('web_editor.transcoder'); - -var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin; - -var QWeb = core.qweb; -var _t = core._t; - - -/** - * FieldTextHtmlSimple Widget - * Intended to display HTML content. This widget uses the summernote editor - * improved by odoo. - * - */ -var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { - className: 'oe_form_field oe_form_field_html_text', - supportedFieldTypes: ['html'], - - /** - * @override - */ - start: function () { - new SummernoteManager(this); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Summernote doesn't notify for changes done in code mode. We override - * commitChanges to manually switch back to normal mode before committing - * changes, so that the widget is aware of the changes done in code mode. - * - * @override - */ - commitChanges: function () { - // switch to WYSIWYG mode if currently in code mode to get all changes - if (config.debug && this.mode === 'edit') { - var layoutInfo = this.$textarea.data('layoutInfo'); - $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); - } - if (this._getValue() !== this.value) { - this._isDirty = true; - } - this._super.apply(this, arguments); - }, - /** - * @override - */ - isSet: function () { - return this.value && this.value !== "<p><br/></p>" && this.value.match(/\S/); - }, - /** - * @override - */ - getFocusableElement: function () { - return this.$content || this._super.apply(this, arguments); - }, - /** - * Do not re-render this field if it was the origin of the onchange call. - * - * @override - */ - reset: function (record, event) { - this._reset(record, event); - if (!event || event.target !== this) { - if (this.mode === 'edit') { - this.$content.html(this._textToHtml(this.value)); - } else { - this._renderReadonly(); - } - } - return $.when(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Returns the domain for attachments used in media dialog. - * We look for attachments related to the current document. If there is a value for the model - * field, it is used to search attachments, and the attachments from the current document are - * filtered to display only user-created documents. - * In the case of a wizard such as mail, we have the documents uploaded and those of the model - * - * @private - * @returns {Array} "ir.attachment" odoo domain. - */ - _getAttachmentsDomain: function () { - var domain = ['|', ['id', 'in', _.pluck(this.attachments, 'id')]]; - var attachedDocumentDomain = [ - '&', - ['res_model', '=', this.model], - ['res_id', '=', this.res_id|0] - ]; - // if the document is not yet created, do not see the documents of other users - if (!this.res_id) { - attachedDocumentDomain.unshift('&'); - attachedDocumentDomain.push(['create_uid', '=', session.uid]); - } - if (this.recordData.res_model || this.recordData.model) { - var relatedDomain = ['&', - ['res_model', '=', this.recordData.res_model || this.recordData.model], - ['res_id', '=', this.recordData.res_id|0]]; - if (!this.recordData.res_id) { - relatedDomain.unshift('&'); - relatedDomain.push(['create_uid', '=', session.uid]); - } - domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain); - } else { - domain = domain.concat(attachedDocumentDomain); - } - return domain; - }, - /** - * @private - * @returns {Object} the summernote configuration - */ - _getSummernoteConfig: function () { - var summernoteConfig = { - model: this.model, - id: this.res_id, - focus: false, - height: 180, - toolbar: [ - ['style', ['style']], - ['font', ['bold', 'italic', 'underline', 'clear']], - ['fontsize', ['fontsize']], - ['color', ['color']], - ['para', ['ul', 'ol', 'paragraph']], - ['table', ['table']], - ['insert', this.nodeOptions['no-attachment'] ? ['link'] : ['link', 'picture']], - ['history', ['undo', 'redo']] - ], - prettifyHtml: false, - styleWithSpan: false, - inlinemedia: ['p'], - lang: "odoo", - onChange: this._doDebouncedAction.bind(this), - disableDragAndDrop: !!this.nodeOptions['no-attachment'], - }; - - var fieldNameAttachment =_.chain(this.recordData) - .pairs() - .find(function (value) { - return _.isObject(value[1]) && value[1].model === "ir.attachment"; - }) - .first() - .value(); - - if (fieldNameAttachment) { - this.fieldNameAttachment = fieldNameAttachment; - this.attachments = []; - summernoteConfig.onUpload = this._onUpload.bind(this); - } - summernoteConfig.getMediaDomain = this._getAttachmentsDomain.bind(this); - - - if (config.debug) { - summernoteConfig.toolbar.splice(7, 0, ['view', ['codeview']]); - } - return summernoteConfig; - }, - /** - * @override - * @private - */ - _getValue: function () { - if (this.nodeOptions['style-inline']) { - transcoder.attachmentThumbnailToLinkImg(this.$content); - transcoder.fontToImg(this.$content); - transcoder.classToStyle(this.$content); - } - return this.$content.html(); - }, - /** - * trigger_up 'field_changed' add record into the "ir.attachment" field found in the view. - * This method is called when an image is uploaded by the media dialog. - * - * For e.g. when sending email, this allows people to add attachments with the content - * editor interface and that they appear in the attachment list. - * The new documents being attached to the email, they will not be erased by the CRON - * when closing the wizard. - * - * @private - */ - _onUpload: function (attachments) { - var self = this; - attachments = _.filter(attachments, function (attachment) { - return !_.findWhere(self.attachments, {id: attachment.id}); - }); - if (!attachments.length) { - return; - } - this.attachments = this.attachments.concat(attachments); - this.trigger_up('field_changed', { - dataPointID: this.dataPointID, - changes: _.object([this.fieldNameAttachment], [{ - operation: 'ADD_M2M', - ids: attachments - }]) - }); - }, - /** - * @override - * @private - */ - _renderEdit: function () { - this.$textarea = $('<textarea>'); - this.$textarea.appendTo(this.$el); - this.$textarea.summernote(this._getSummernoteConfig()); - this.$content = this.$('.note-editable:first'); - this.$content.html(this._textToHtml(this.value)); - // trigger a mouseup to refresh the editor toolbar - var mouseupEvent = $.Event('mouseup', {'setStyleInfoFromEditable': true}); - this.$content.trigger(mouseupEvent); - if (this.nodeOptions['style-inline']) { - transcoder.styleToClass(this.$content); - transcoder.imgToFont(this.$content); - transcoder.linkImgToAttachmentThumbnail(this.$content); - } - // reset the history (otherwise clicking on undo before editing the - // value will empty the editor) - var history = this.$content.data('NoteHistory'); - if (history) { - history.reset(); - } - this.$('.note-toolbar').append(this._renderTranslateButton()); - }, - /** - * @override - * @private - */ - _renderReadonly: function () { - var self = this; - this.$el.empty(); - if (this.nodeOptions['style-inline']) { - var $iframe = $('<iframe class="o_readonly"/>'); - $iframe.on('load', function () { - self.$content = $($iframe.contents()[0]).find("body"); - self.$content.html(self._textToHtml(self.value)); - self._resize(); - }); - $iframe.appendTo(this.$el); - } else { - this.$content = $('<div class="o_readonly"/>'); - this.$content.html(this._textToHtml(this.value)); - this.$content.appendTo(this.$el); - } - }, - /** - * Sets the height of the iframe. - * - * @private - */ - _resize: function () { - var height = this.$content[0] ? this.$content[0].scrollHeight : 0; - this.$('iframe').css('height', Math.max(30, Math.min(height, 500)) + 'px'); - }, - /** - * @private - * @param {string} text - * @returns {string} the text converted to html - */ - _textToHtml: function (text) { - var value = text || ""; - try { - $(text)[0].innerHTML; // crashes if text isn't html - } catch (e) { - if (value.match(/^\s*$/)) { - value = '<p><br/></p>'; - } else { - value = "<p>" + value.split(/<br\/?>/).join("<br/></p><p>") + "</p>"; - value = value - .replace(/<p><\/p>/g, '') - .replace('<p><p>', '<p>') - .replace('<p><p ', '<p ') - .replace('</p></p>', '</p>'); - } - } - return value; - }, - /** - * @override - * @private - * @returns {jQueryElement} - */ - _renderTranslateButton: function () { - if (_t.database.multi_lang && this.field.translate && this.res_id) { - return $(QWeb.render('web_editor.FieldTextHtml.button.translate', {widget: this})) - .on('click', this._onTranslate.bind(this)); - } - return $(); - }, - -}); - -var FieldTextHtml = AbstractField.extend({ - template: 'web_editor.FieldTextHtml', - supportedFieldTypes: ['html'], - - start: function () { - var self = this; - - this.editorLoadedDeferred = $.Deferred(); - this.contentLoadedDeferred = $.Deferred(); - this.callback = _.uniqueId('FieldTextHtml_'); - window.odoo[this.callback+"_editor"] = function (EditorBar) { - setTimeout(function () { - self.on_editor_loaded(EditorBar); - },0); - }; - window.odoo[this.callback+"_content"] = function () { - self.on_content_loaded(); - }; - window.odoo[this.callback+"_updown"] = null; - window.odoo[this.callback+"_downup"] = function () { - self.resize(); - }; - - // init jqery objects - this.$iframe = this.$el.find('iframe'); - this.document = null; - this.$body = $(); - this.$content = $(); - - this.$iframe.css('min-height', 'calc(100vh - 360px)'); - - // init resize - this.resize = function resize() { - if (self.mode === 'edit') { - if ($("body").hasClass("o_field_widgetTextHtml_fullscreen")) { - self.$iframe.css('height', (document.body.clientHeight - self.$iframe.offset().top) + 'px'); - } else { - self.$iframe.css("height", (self.$body.find("#oe_snippets").length ? 500 : 300) + "px"); - } - } - }; - $(window).on('resize', this.resize); - - this.old_initialize_content(); - var def = this._super.apply(this, arguments); - return def; - }, - getDatarecord: function () { - return this.recordData; - }, - get_url: function (_attr) { - var src = this.nodeOptions.editor_url || "/mass_mailing/field/email_template"; - var k; - var datarecord = this.getDatarecord(); - var attr = { - 'model': this.model, - 'field': this.name, - 'res_id': datarecord.id || '', - 'callback': this.callback - }; - _attr = _attr || {}; - - if (this.nodeOptions['style-inline']) { - attr.inline_mode = 1; - } - if (this.nodeOptions.snippets) { - attr.snippets = this.nodeOptions.snippets; - } - if (this.nodeOptions.template) { - attr.template = this.nodeOptions.template; - } - if (this.mode === "edit") { - attr.enable_editor = 1; - } - if (session.debug) { - attr.debug = session.debug; - } - - for (k in _attr) { - attr[k] = _attr[k]; - } - - if (src.indexOf('?') === -1) { - src += "?"; - } - - for (k in attr) { - if (attr[k] !== null) { - src += "&"+k+"="+(_.isBoolean(attr[k]) ? +attr[k] : attr[k]); - } - } - - // delete datarecord[this.name]; - src += "&datarecord="+ encodeURIComponent(JSON.stringify(datarecord)); - return src; - }, - old_initialize_content: function () { - this.$el.closest('.modal-body').css('max-height', 'none'); - this.$iframe = this.$el.find('iframe'); - this.document = null; - this.$body = $(); - this.$content = $(); - this.editor = false; - window.odoo[this.callback+"_updown"] = null; - this.$iframe.attr("src", this.get_url()); - }, - on_content_loaded: function () { - var self = this; - this.document = this.$iframe.contents()[0]; - this.$body = $("body", this.document); - this.$content = this.$body.find("#editable_area"); - this.render(); - this.add_button(); - this.contentLoadedDeferred.resolve(); - setTimeout(self.resize, 0); - }, - on_editor_loaded: function (EditorBar) { - var self = this; - this.editor = EditorBar; - if (this.value && window.odoo[self.callback+"_updown"] && !(this.$content.html()||"").length) { - this.render(); - } - this.editorLoadedDeferred.resolve(); - setTimeout(function () { - setTimeout(self.resize,0); - }, 0); - }, - add_button: function () { - var self = this; - var $to = this.$body.find("#web_editor-top-edit, #wrapwrap").first(); - - $(QWeb.render('web_editor.FieldTextHtml.fullscreen')) - .appendTo($to) - .on('click', '.o_fullscreen', function () { - $("body").toggleClass("o_field_widgetTextHtml_fullscreen"); - var full = $("body").hasClass("o_field_widgetTextHtml_fullscreen"); - self.$iframe.parents().toggleClass('o_form_fullscreen_ancestor', full); - $(window).trigger("resize"); // induce a resize() call and let other backend elements know (the navbar extra items management relies on this) - }); - - this.$body.on('click', '[data-action="cancel"]', function (event) { - event.preventDefault(); - self.old_initialize_content(); - }); - }, - render: function () { - var value = (this.value || "").replace(/^<p[^>]*>(\s*|<br\/?>)<\/p>$/, ''); - if (!this.$content) { - return; - } - if (this.mode === "edit") { - if (window.odoo[this.callback+"_updown"]) { - // FIXME - // window.odoo[this.callback+"_updown"](value, this.view.get_fields_values(), this.name); - this.resize(); - } - } else { - this.$content.html(value); - if (this.$iframe[0].contentWindow) { - this.$iframe.css("height", (this.$body.height()+20) + "px"); - } - } - }, - has_no_value: function () { - return this.value === false || !this.$content.html() || !this.$content.html().match(/\S/); - }, - destroy: function () { - $(window).off('resize', this.resize); - delete window.odoo[this.callback+"_editor"]; - delete window.odoo[this.callback+"_content"]; - delete window.odoo[this.callback+"_updown"]; - delete window.odoo[this.callback+"_downup"]; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Set the value when the widget is fully loaded (content + editor). - * - * @override - */ - commitChanges: function () { - var self = this; - var result = this._super.bind(this, arguments); - if (this.mode === 'readonly') { - return; - } - return $.when(this.contentLoadedDeferred, this.editorLoadedDeferred, result).then(function () { - // switch to WYSIWYG mode if currently in code mode to get all changes - if (config.debug && self.editor.rte) { - var layoutInfo = self.editor.rte.editable().data('layoutInfo'); - $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); - } - var $ancestors = self.$iframe.filter(':not(:visible)').parentsUntil(':visible').addBack(); - var ancestorsStyle = []; - // temporarily force displaying iframe (needed for firefox) - _.each($ancestors, function (el) { - var $el = $(el); - ancestorsStyle.unshift($el.attr('style') || null); - $el.css({display: 'initial', visibility: 'hidden', height: 1}); - }); - self.editor.snippetsMenu && self.editor.snippetsMenu.cleanForSave(); - _.each($ancestors, function (el) { - var $el = $(el); - $el.attr('style', ancestorsStyle.pop()); - }); - self._setValue(self.$content.html()); - }); - }, -}); - -field_registry - .add('html', FieldTextHtmlSimple) - .add('html_frame', FieldTextHtml); - -return { - FieldTextHtmlSimple: FieldTextHtmlSimple, - FieldTextHtml: FieldTextHtml, -}; -}); diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/common/ace.js index f3c47f30050..2379a938d1f 100644 --- a/addons/web_editor/static/src/js/widgets/ace.js +++ b/addons/web_editor/static/src/js/common/ace.js @@ -5,7 +5,6 @@ var ajax = require('web.ajax'); var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); -var weContext = require('web_editor.context'); var localStorage = require('web.local_storage'); var session = require('web.session'); @@ -157,6 +156,8 @@ var ViewEditor = Widget.extend({ init: function (parent, viewKey, options) { this._super.apply(this, arguments); + this.context = options.context; + this.viewKey = viewKey; this.options = _.defaults({}, options, { position: 'right', diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js deleted file mode 100644 index 63fa8c1c667..00000000000 --- a/addons/web_editor/static/src/js/editor/editor.js +++ /dev/null @@ -1,286 +0,0 @@ -odoo.define('web_editor.editor', function (require) { -'use strict'; - -var Dialog = require('web.Dialog'); -var Widget = require('web.Widget'); -var core = require('web.core'); -var rte = require('web_editor.rte'); -var snippetsEditor = require('web_editor.snippet.editor'); - -var _t = core._t; - -var EditorMenuBar = Widget.extend({ - template: 'web_editor.editorbar', - xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], - events: { - '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.rte = new rte.Class(this); - this.rte.on('rte:start', this, function () { - self.trigger('rte:start'); - }); - - // Snippets edition - var $editable = this.rte.editable(); - window.__EditorMenuBar_$editable = $editable; // TODO remove this hack asap - 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.save); - core.bus.on('editor_discard_request', this, this.cancel); - - $('.dropdown-toggle').dropdown(); - - $(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.start(); - - var flag = false; - window.onbeforeunload = function (event) { - if (rte.history.getEditableHasUndo().length && !flag) { - flag = true; - _.defer(function () { flag=false; }); - return _t('This document is not saved!'); - } - }; - - // Snippets menu - defs.push(this.snippetsMenu.insertAfter(this.$el)); - this.rte.editable().find('*').off('mousedown mouseup click'); - - return $.when.apply($, defs).then(function () { - self.trigger_up('edit_mode'); - }); - }, - /** - * @override - */ - 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); - }, - - //-------------------------------------------------------------------------- - // 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', 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; - var defs = []; - this.trigger_up('ready_to_save', {defs: defs}); - return $.when.apply($, defs).then(function () { - self.snippetsMenu.cleanForSave(); - return self._saveCroppedImages(); - }).then(function () { - return self.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(); - }, - /** - * @private - */ - _saveCroppedImages: function () { - var self = this; - var defs = _.map(this.rte.editable().find('.o_cropped_img_to_save'), function (croppedImg) { - var $croppedImg = $(croppedImg); - $croppedImg.removeClass('o_cropped_img_to_save'); - - var resModel = $croppedImg.data('crop:resModel'); - var resID = $croppedImg.data('crop:resID'); - var cropID = $croppedImg.data('crop:id'); - var mimetype = $croppedImg.data('crop:mimetype'); - var originalSrc = $croppedImg.data('crop:originalSrc'); - - var datas = $croppedImg.attr('src').split(',')[1]; - - if (!cropID) { - var name = originalSrc + '.crop'; - return self._rpc({ - model: 'ir.attachment', - method: 'create', - args: [{ - res_model: resModel, - res_id: resID, - name: name, - datas_fname: name, - datas: datas, - mimetype: mimetype, - url: originalSrc, // To save the original image that was cropped - }], - }).then(function (attachmentID) { - return self._rpc({ - model: 'ir.attachment', - method: 'generate_access_token', - args: [[attachmentID]], - }).then(function (access_token) { - $croppedImg.attr('src', '/web/image/' + attachmentID + '?access_token=' + access_token[0]); - }); - }); - } else { - return self._rpc({ - model: 'ir.attachment', - method: 'write', - args: [[cropID], {datas: datas}], - }); - } - }); - return $.when.apply($, defs); - }, - - //-------------------------------------------------------------------------- - // 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 { - Class: EditorMenuBar, -}; -}); diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js deleted file mode 100644 index ba6e1fed7ec..00000000000 --- a/addons/web_editor/static/src/js/editor/rte.js +++ /dev/null @@ -1,749 +0,0 @@ -odoo.define('web_editor.rte', function (require) { -'use strict'; - -var base = require('web_editor.base'); -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 summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); - -var _t = core._t; - -// 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) { - var aUndo = []; - var pos = 0; - var toSnap; - - this.makeSnap = function (event, rng) { - rng = rng || range.create(); - var elEditable = $(rng && rng.sc).closest('.o_editable')[0]; - if (!elEditable) { - return false; - } - return { - event: event, - editable: elEditable, - contents: elEditable.innerHTML, - bookmark: rng && rng.bookmark(elEditable), - scrollTop: $(elEditable).scrollTop() - }; - }; - - this.applySnap = function (oSnap) { - var $editable = $(oSnap.editable); - - if (document.documentMode) { - $editable.removeAttr('contentEditable').removeProp('contentEditable'); - } - - $editable.trigger('content_will_be_destroyed'); - $editable.html(oSnap.contents).scrollTop(oSnap.scrollTop); - $editable.trigger('content_was_recreated'); - - $('.oe_overlay').remove(); - $('.note-control-selection').hide(); - - $editable.trigger('content_changed'); - - try { - var r = oSnap.editable.innerHTML === '' ? range.create(oSnap.editable, 0) : range.createFromBookmark(oSnap.editable, oSnap.bookmark); - r.select(); - } catch (e) { - console.error(e); - return; - } - - $(document).trigger('click'); - $('.o_editable *').filter(function () { - var $el = $(this); - if ($el.data('snippet-editor')) { - $el.removeData(); - } - }); - - - _.defer(function () { - var target = dom.isBR(r.sc) ? r.sc.parentNode : dom.node(r.sc); - if (!target) { - return; - } - - $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); - target.dispatchEvent(evt); - - $editable.trigger('keyup'); - }); - }; - - this.undo = function () { - if (!pos) { return; } - var _toSnap = toSnap; - if (_toSnap) { - this.saveSnap(); - } - if (!aUndo[pos] && (!aUndo[pos] || aUndo[pos].event !== 'undo')) { - var temp = this.makeSnap('undo'); - if (temp && (!pos || temp.contents !== aUndo[pos-1].contents)) { - aUndo[pos] = temp; - } else { - pos--; - } - } else if (_toSnap) { - 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))) { - this.applySnap(aUndo[--pos]); - } - }; - - this.hasUndo = function () { - 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'; - }); - }; - - this.getEditableHasUndo = function () { - var editable = []; - 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') { - editable.push(undo.editable); - } - }); - return _.uniq(editable); - }; - - this.redo = function () { - if (!aUndo[pos+1]) { return; } - this.applySnap(aUndo[++pos]); - while (aUndo[pos+1] && aUndo[pos].event === 'active') { - this.applySnap(aUndo[pos++]); - } - }; - - this.hasRedo = function () { - return aUndo.length > pos+1; - }; - - 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 (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 - setTimeout(function () { - $editable.trigger('content_changed'); - },0); - } - } - - if (aUndo[pos]) { - pos = Math.min(pos, aUndo.length); - aUndo.splice(pos, aUndo.length); - } - - // => make a snap when the user change editable zone (because: don't make snap for each keydown) - if (toSnap && (toSnap.split || !event || toSnap.event !== event || toSnap.editable !== $editable[0])) { - this.saveSnap(); - } - - if (pos && aUndo[pos-1].editable !== $editable[0]) { - var snap = this.makeSnap('blur', range.create(aUndo[pos-1].editable, 0)); - pos++; - aUndo.push(snap); - } - - if (range.create()) { - toSnap = self.makeSnap(event); - } else { - toSnap = false; - } - }; - - this.splitNext = function () { - if (toSnap) { - toSnap.split = true; - } - }; - - this.saveSnap = function () { - if (toSnap) { - if (!aUndo[pos]) { - pos++; - } - aUndo.push(toSnap); - delete toSnap.split; - toSnap = null; - } - }; -}; -var history = new History(); - -// 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 ) { - return false; - } - if (node.className.indexOf('o_editable')!==-1 ) { - return true; - } - } - node = node.parentNode; - } - return false; - }, -}); -$.fn.extend({ - focusIn: function () { - if (this.length) { - range.create(dom.firstChild(this[0]), 0).select(); - } - return this; - }, - focusInEnd: function () { - if (this.length) { - var last = dom.lastChild(this[0]); - range.create(last, dom.nodeLength(last)).select(); - } - return this; - }, - selectContent: function () { - if (this.length) { - var next = dom.lastChild(this[0]); - range.create(dom.firstChild(this[0]), 0, next, next.textContent.length).select(); - } - return this; - }, -}); - -// RTE -var RTEWidget = Widget.extend({ - /** - * @constructor - */ - init: function (parent, getConfig) { - var self = this; - this._super.apply(this, arguments); - - this.init_bootstrap_carousel = $.fn.carousel; - this.edit_bootstrap_carousel = function () { - var res = self.init_bootstrap_carousel.apply(this, arguments); - // off bootstrap keydown event to remove event.preventDefault() - // and allow to change cursor position - $(this).off('keydown.bs.carousel'); - return res; - }; - - this._getConfig = getConfig || this._getDefaultConfig; - - base.computeFonts(); - }, - /** - * @override - */ - start: function () { - var self = this; - - this.saving_mutex = new concurrency.Mutex(); - - $.fn.carousel = this.edit_bootstrap_carousel; - - $(document).on('click.rte keyup.rte', function () { - var current_range = {}; - try { - current_range = range.create() || {}; - } catch (e) { - // if range is on Restricted element ignore error - } - var $popover = $(current_range.sc).closest('[contenteditable]'); - var popover_history = ($popover.data()||{}).NoteHistory; - if (!popover_history || popover_history === history) return; - var editor = $popover.parent('.note-editor'); - $('button[data-event="undo"]', editor).attr('disabled', !popover_history.hasUndo()); - $('button[data-event="redo"]', editor).attr('disabled', !popover_history.hasRedo()); - }); - $(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(); - - // When a undo/redo is performed, the whole DOM is changed so we have - // to prepare for it (website will restart animations for example) - // TODO should be better handled - $editable.on('content_will_be_destroyed', function (ev) { - self.trigger_up('content_will_be_destroyed', { - $target: $(ev.currentTarget), - }); - }); - $editable.on('content_was_recreated', function (ev) { - self.trigger_up('content_was_recreated', { - $target: $(ev.currentTarget), - }); - }); - - $editable.addClass('o_editable') - .data('rte', this) - .each(function () { - var $node = $(this); - - // fallback for firefox iframe display:none see https://github.com/odoo/odoo/pull/22610 - var computedStyles = window.getComputedStyle(this) || window.parent.getComputedStyle(this); - // add class to display inline-block for empty t-field - if (computedStyles.display === 'inline' && $node.data('oe-type') !== 'image') { - $node.addClass('o_is_inline_editable'); - } - }); - - // start element observation - $(document).on('content_changed', '.o_editable', function (ev) { - self.trigger_up('rte_change', {target: ev.target}); - if (!ev.__isDirtyHandled) { - $(this).addClass('o_dirty'); - ev.__isDirtyHandled = true; - } - }); - - $('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this)); - - $('body').addClass('editor_enable'); - - $(document.body) - .tooltip({ - selector: '[data-oe-readonly]', - container: 'body', - trigger: 'hover', - delay: { 'show': 1000, 'hide': 100 }, - placement: 'bottom', - title: _t("Readonly field") - }) - .on('click', function () { - $(this).tooltip('hide'); - }); - - $(document).trigger('mousedown'); - this.trigger('rte:start'); - - return this._super.apply(this, arguments); - }, - /** - * @override - */ - destroy: function () { - this.cancel(); - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Stops the RTE. - */ - cancel: function () { - if (this.$last) { - this.$last.destroy(); - this.$last = null; - } - - $.fn.carousel = this.init_bootstrap_carousel; - - $(document).off('.rte'); - $('#wrapwrap, .o_editable').off('.rte'); - - $('.o_not_editable').removeAttr('contentEditable'); - - $(document).off('click.rte keyup.rte mousedown.rte activate.rte mouseup.rte'); - $(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte'); - $(document).tooltip('dispose'); - $('body').removeClass('editor_enable'); - this.trigger('rte: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'); - }, - /** - * 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) { - $target = $($target); - 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; - } - try { - // TODO this line might break for unknown reasons. I suppose that - // the created range is an invalid one. As it might be tricky to - // adapt that line and that it is not a critical one, temporary fix - // is to ignore the errors that this generates. - rng.select(); - } catch (e) { - console.log('error', e); - } - 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; - - $('.o_editable') - .destroy() - .removeClass('o_editable o_is_inline_editable'); - - 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.getAttribute('class').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 = $('<div/>', {text: response.data.message}).html(); - var data = msg.substring(3, msg.length -2).split(/', u'/); - response.data.message = '<b>' + data[0] + '</b>' + 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'); - }, - 'colors': summernoteCustomColors, - }; - }, - /** - * 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 = $('<div />').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, - ], - context: context, - }, withLang ? undefined : { - noContextKeys: 'lang', - }); - }, - - //-------------------------------------------------------------------------- - // 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 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 || $.summernote.core.dom.isContentEditableFalse($target)) { - return; - } - - // Removes strange _moz_abspos attribute when it appears. Cannot - // find another solution which works in all cases. A grabber still - // appears at the same time which I did not manage to remove. - // TODO find a complete and better solution - _.defer(function () { - $editable.find('[_moz_abspos]').removeAttr('_moz_abspos'); - }); - - if ($target.is('a')) { - /** - * Remove content editable everywhere and add it on the link only so that characters can be added - * and removed at the start and at the end of it. - */ - $target.attr('contenteditable', true); - _.defer(function () { - $editable.not($target).attr('contenteditable', false); - $target.focus(); - }); - - // Once clicked outside, remove contenteditable on link and reactive all - $(document).on('mousedown.reactivate_contenteditable', function (e) { - if ($target.is(e.target)) return; - $target.removeAttr('contenteditable'); - $editable.attr('contenteditable', true); - $(document).off('mousedown.reactivate_contenteditable'); - }); - } - - if (this && this.$last && (!$editable.length || this.$last[0] !== $editable[0])) { - var $destroy = this.$last; - history.splitNext(); - - _.delay(function () { - var id = $destroy.data('note-id'); - $destroy.destroy().removeData('note-id').removeAttr('data-note-id'); - $('#note-popover-'+id+', #note-handle-'+id+', #note-dialog-'+id+'').remove(); - }, 150); // setTimeout to remove flickering when change to editable zone (re-create an editor) - this.$last = null; - } - if ($editable.length && (!this.$last || this.$last[0] !== $editable[0])) { - $editable.summernote(this._getConfig($editable)); - - $editable.data('NoteHistory', history); - this.$last = $editable; - - // firefox & IE fix - try { - document.execCommand('enableObjectResizing', false, false); - document.execCommand('enableInlineTableEditing', false, false); - document.execCommand('2D-position', false, false); - } 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;}); - - if (!range.create()) { - $editable.focusIn(); - } - - if (dom.isImg($target[0])) { - $target.trigger('mousedown'); // for activate selection on picture - } - - this._enableEditableArea($editable); - } - }, - /** - * 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) { - return; - } - - var self = this; - _.defer(function () { - self.historyRecordUndo($target, 'activate', true); - }); - - // Browsers select different content from one to another after a - // triple click (especially: if triple-clicking on a paragraph on - // Chrome, blank characters of the element following the paragraph are - // selected too) - // - // The triple click behavior is reimplemented for all browsers here - if (ev.originalEvent.detail === 3) { - // Select the whole content inside the deepest DOM element that was - // triple-clicked - range.create(ev.target, 0, ev.target, ev.target.childNodes.length).select(); - } - }, -}); - -return { - Class: RTEWidget, - history: history, -}; -}); - -odoo.define('web_editor.rte.summernote_custom_colors', function (require) { -'use strict'; - -return [ - ['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'], - ['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'], - ['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'], - ['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'], - ['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'], - ['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'], - ['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'], - ['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031'] -]; -}); diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js deleted file mode 100644 index 62586a53b5c..00000000000 --- a/addons/web_editor/static/src/js/editor/rte.summernote.js +++ /dev/null @@ -1,1274 +0,0 @@ -odoo.define('web_editor.rte.summernote', function (require) { -'use strict'; - -var ajax = require('web.ajax'); -var Class = require('web.Class'); -var core = require('web.core'); -var ColorpickerDialog = require('web.colorpicker'); -var mixins = require('web.mixins'); -var base = require('web_editor.base'); -var weContext = require('web_editor.context'); -var rte = require('web_editor.rte'); -var weWidgets = require('web_editor.widget'); - -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', weContext.get()] -}).done(function (data) { - QWeb.add_template(data); -}); - -// 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 tplButton = renderer.getTemplate().button; -var tplIconButton = renderer.getTemplate().iconButton; -var tplDropdown = renderer.getTemplate().dropdown; - -function _rgbToHex(cssColor) { - var rgba = cssColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); - if (!rgba) { - return cssColor; - } - if (rgba[4]) { - return cssColor; - } - var hex = ColorpickerDialog.prototype.convertRgbToHex( - parseInt(rgba[1]), - parseInt(rgba[2]), - parseInt(rgba[3]) - ); - if (!hex) { - return cssColor; // TODO handle error - } - return hex.hex.toUpperCase(); -} - -// 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); - - if (!QWeb.has_template('web_editor.colorpicker')) { - return; - } - - var $clpicker = $(QWeb.render('web_editor.colorpicker')); - - var groups; - if ($clpicker.is("colorpicker")) { - groups = _.map($clpicker.find('[data-name="theme"]'), function (el) { - return $(el).find("button").empty(); - }); - } else { - groups = [$clpicker.find("button").empty()]; - } - - var html = "<h6 class='mt-2'>" + _t("Theme colors") + "</h6>" + _.map(groups, function ($group) { - var $row = $("<div/>", {"class": "note-color-row mb8"}).append($group); - var $after_breaks = $row.find(".o_small + :not(.o_small)"); - if ($after_breaks.length === 0) { - $after_breaks = $row.find(":nth-child(8n+9)"); - } - $after_breaks.addClass("o_clear"); - return $row[0].outerHTML; - }).join("") + "<h6 class='mt-2'>" + _t("Common colors") + "</h6>"; - var $palettes = $container.find(".note-color .note-color-palette"); - $palettes.prepend(html); - - // Find the custom colors which are used in the page and add them to the color palette - var colors = []; - var $editable = window.__EditorMenuBar_$editable || $(); - _.each($editable.find('[style*="color"]'), function (element) { - if (element.style.color) { - colors.push(element.style.color); - } - if (element.style.backgroundColor) { - colors.push(element.style.backgroundColor); - } - }); - - var $customColorPalettes = $container.find('.note-color .note-custom-color-palette').append($('<div/>', {class: "note-color-row"})); - var $customColorRows = $customColorPalettes.find('.note-color-row'); - _.each(_.uniq(colors), function (color) { - var hexColor = _rgbToHex(color); - if (_.indexOf(_.flatten(options.colors), hexColor) < 0) { - // Create button for used custom color for backColor and foreColor both and add them into palette - $customColorRows.append('<button type="button" class="o_custom_color" data-color="' + color + '" style="background-color:' + color + ';" />'); - } - }); - - $palettes.push.apply($palettes, $customColorPalettes); - - var $fore = $palettes.filter(":even").find("button:not(.note-color-btn)").addClass("note-color-btn"); - var $bg = $palettes.filter(":odd").find("button:not(.note-color-btn)").addClass("note-color-btn"); - $fore.each(function () { - var $el = $(this); - var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'text-' + $el.data('color'); - $el.attr('data-event', 'foreColor').attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : 'bg-' + $el.data('color')); - }); - $bg.each(function () { - var $el = $(this); - var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'bg-' + $el.data('color'); - $el.attr('data-event', 'backColor').attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : className); - }); -}; - -var fn_tplPopovers = renderer.tplPopovers; -renderer.tplPopovers = function (lang, options) { - var $popover = $(fn_tplPopovers.call(this, lang, options)); - - var $imagePopover = $popover.find('.note-image-popover'); - var $linkPopover = $popover.find('.note-link-popover'); - var $airPopover = $popover.find('.note-air-popover'); - - //////////////// image popover - - // add center button for images - $(tplIconButton('fa fa-align-center', { - title: _t('Center'), - event: 'floatMe', - value: 'center' - })).insertAfter($imagePopover.find('[data-event="floatMe"][data-value="left"]')); - $imagePopover.find('button[data-event="removeMedia"]').parent().remove(); - $imagePopover.find('button[data-event="floatMe"][data-value="none"]').remove(); - - // padding button - var $padding = $('<div class="btn-group"/>'); - $padding.insertBefore($imagePopover.find('.btn-group:first')); - var dropdown_content = [ - '<li><a class="dropdown-item" data-event="padding" href="#" data-value="">'+_t('None')+'</a></li>', - '<li><a class="dropdown-item" data-event="padding" href="#" data-value="small">'+_t('Small')+'</a></li>', - '<li><a class="dropdown-item" data-event="padding" href="#" data-value="medium">'+_t('Medium')+'</a></li>', - '<li><a class="dropdown-item" data-event="padding" href="#" data-value="large">'+_t('Large')+'</a></li>', - '<li><a class="dropdown-item" data-event="padding" href="#" data-value="xl">'+_t('Xl')+'</a></li>', - ]; - $(tplIconButton('fa fa-plus-square-o', { - title: _t('Padding'), - dropdown: tplDropdown(dropdown_content) - })).appendTo($padding); - - // circle, boxed... options became toggled - $imagePopover.find('[data-event="imageShape"]:not([data-value])').remove(); - var $button = $(tplIconButton('fa fa-sun-o', { - title: _t('Shadow'), - event: 'imageShape', - value: 'shadow' - })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="rounded-circle"]')); - - // add spin for fa - var $spin = $('<div class="btn-group d-none only_fa"/>').insertAfter($button.parent()); - $(tplIconButton('fa fa-refresh', { - title: _t('Spin'), - event: 'imageShape', - value: 'fa-spin' - })).appendTo($spin); - - // resize for fa - var $resizefa = $('<div class="btn-group d-none only_fa"/>') - .insertAfter($imagePopover.find('.btn-group:has([data-event="resize"])')); - for (var size=1; size<=5; size++) { - $(tplButton('<span class="note-fontsize-10">'+size+'x</span>', { - title: size+"x", - event: 'resizefa', - value: size+'' - })).appendTo($resizefa); - } - var $colorfa = $airPopover.find('.note-color').clone(); - $colorfa.find(".dropdown-menu").css('min-width', '172px'); - $resizefa.after($colorfa); - - // show dialog box and delete - var $imageprop = $('<div class="btn-group"/>'); - $imageprop.appendTo($imagePopover.find('.popover-body')); - $(tplIconButton('fa fa-file-image-o', { - title: _t('Edit'), - event: 'showImageDialog' - })).appendTo($imageprop); - $(tplIconButton('fa fa-trash-o', { - title: _t('Remove'), - event: 'delete' - })).appendTo($imageprop); - - $(tplIconButton('fa fa-crop', { - title: _t('Crop Image'), - event: 'cropImage', - })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="img-thumbnail"]')); - - $imagePopover.find('.popover-body').append($airPopover.find(".note-history").clone()); - - $imagePopover.find('[data-event="showImageDialog"]').before($airPopover.find('[data-event="showLinkDialog"]').clone()); - - var $alt = $('<div class="btn-group"/>'); - $alt.appendTo($imagePopover.find('.popover-body')); - $alt.append('<button class="btn btn-secondary" data-event="alt"><strong>' + _t('Description') + ': </strong><span class="o_image_alt"/></button>'); - - //////////////// link popover - - $linkPopover.find('.popover-body').append($airPopover.find(".note-history").clone()); - - $linkPopover.find('button[data-event="showLinkDialog"] i').attr("class", "fa fa-link"); - $linkPopover.find('button[data-event="unlink"]').before($airPopover.find('button[data-event="showImageDialog"]').clone()); - - //////////////// text/air popover - - //// highlight the text format - $airPopover.find('.note-style .dropdown-toggle').on('mousedown', function () { - var $format = $airPopover.find('[data-event="formatBlock"]'); - var node = range.create().sc; - var formats = $format.map(function () { return $(this).data("value"); }).get(); - while (node && (!node.tagName || (!node.tagName || formats.indexOf(node.tagName.toLowerCase()) === -1))) { - node = node.parentNode; - } - $format.removeClass('active'); - $format.filter('[data-value="'+(node ? node.tagName.toLowerCase() : "p")+'"]') - .addClass("active"); - }); - - //////////////// tooltip - - setTimeout(function () { - $airPopover.add($linkPopover).add($imagePopover).find("button") - .tooltip('dispose') - .tooltip({ - container: 'body', - trigger: 'hover', - placement: 'bottom' - }).on('click', function () {$(this).tooltip('hide');}); - }); - - return $popover; -}; - -var fn_boutton_update = eventHandler.modules.popover.button.update; -eventHandler.modules.popover.button.update = function ($container, oStyle) { - // stop animation when edit content - var previous = $(".note-control-selection").data('target'); - if (previous) { - var $previous = $(previous); - $previous.css({"-webkit-animation-play-state": "", "animation-play-state": "", "-webkit-transition": "", "transition": "", "-webkit-animation": "", "animation": ""}); - $previous.find('.o_we_selected_image').addBack('.o_we_selected_image').removeClass('o_we_selected_image'); - } - // end - - fn_boutton_update.call(this, $container, oStyle); - - $container.find('.note-color').removeClass('d-none'); - - if (oStyle.image) { - $container.find('[data-event]').removeClass("active"); - - $container.find('a[data-event="padding"][data-value="small"]').toggleClass("active", $(oStyle.image).hasClass("padding-small")); - $container.find('a[data-event="padding"][data-value="medium"]').toggleClass("active", $(oStyle.image).hasClass("padding-medium")); - $container.find('a[data-event="padding"][data-value="large"]').toggleClass("active", $(oStyle.image).hasClass("padding-large")); - $container.find('a[data-event="padding"][data-value="xl"]').toggleClass("active", $(oStyle.image).hasClass("padding-xl")); - $container.find('a[data-event="padding"][data-value=""]').toggleClass("active", !$container.find('li a.active[data-event="padding"]').length); - - $(oStyle.image).addClass('o_we_selected_image'); - - if (dom.isImgFont(oStyle.image)) { - $container.find('[data-event="customColor"][data-value="foreColor"]').attr('data-color', $(oStyle.image).css('color')); - $container.find('[data-event="customColor"][data-value="backColor"]').attr('data-color', $(oStyle.image).css('background-color')); - $container.find('.note-fore-color-preview > button').css('border-bottom-color', $(oStyle.image).css('color')); - $container.find('.note-back-color-preview > button').css('border-bottom-color', $(oStyle.image).css('background-color')); - - $container.find('.btn-group:not(.only_fa):has(button[data-event="resize"],button[data-value="img-thumbnail"])').addClass('d-none'); - $container.find('.only_fa').removeClass('d-none'); - $container.find('button[data-event="resizefa"][data-value="2"]').toggleClass("active", $(oStyle.image).hasClass("fa-2x")); - $container.find('button[data-event="resizefa"][data-value="3"]').toggleClass("active", $(oStyle.image).hasClass("fa-3x")); - $container.find('button[data-event="resizefa"][data-value="4"]').toggleClass("active", $(oStyle.image).hasClass("fa-4x")); - $container.find('button[data-event="resizefa"][data-value="5"]').toggleClass("active", $(oStyle.image).hasClass("fa-5x")); - $container.find('button[data-event="resizefa"][data-value="1"]').toggleClass("active", !$container.find('.active[data-event="resizefa"]').length); - - $container.find('button[data-event="imageShape"][data-value="fa-spin"]').toggleClass("active", $(oStyle.image).hasClass("fa-spin")); - $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow")); - $container.find('.btn-group:has(button[data-event="imageShape"])').removeClass("d-none"); - - } else { - $container.find('.d-none:not(.only_fa, .note-recent-color)').removeClass('d-none'); - $container.find('.only_fa').addClass('d-none'); - var width = ($(oStyle.image).attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+%)/); - if (width) { - width = width[2]; - } - $container.find('button[data-event="resize"][data-value="auto"]').toggleClass("active", width !== "100%" && width !== "50%" && width !== "25%"); - $container.find('button[data-event="resize"][data-value="1"]').toggleClass("active", width === "100%"); - $container.find('button[data-event="resize"][data-value="0.5"]').toggleClass("active", width === "50%"); - $container.find('button[data-event="resize"][data-value="0.25"]').toggleClass("active", width === "25%"); - - $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow")); - - if (!$(oStyle.image).is("img")) { - $container.find('.btn-group:has(button[data-event="imageShape"])').addClass('d-none'); - } - - $container.find('.note-color').addClass('d-none'); - } - - $container.find('button[data-event="floatMe"][data-value="left"]').toggleClass("active", $(oStyle.image).hasClass("float-left")); - $container.find('button[data-event="floatMe"][data-value="center"]').toggleClass("active", $(oStyle.image).hasClass("d-block mx-auto")); - $container.find('button[data-event="floatMe"][data-value="right"]').toggleClass("active", $(oStyle.image).hasClass("float-right")); - - $(oStyle.image).trigger('attributes_change'); - } else { - $container.find('[data-event="customColor"][data-value="foreColor"]').attr('data-color', oStyle.color); - $container.find('[data-event="customColor"][data-value="backColor"]').attr('data-color', oStyle['background-color']); - $container.find('.note-fore-color-preview > button').css('border-bottom-color', oStyle.color); - $container.find('.note-back-color-preview > button').css('border-bottom-color', oStyle['background-color']); - } -}; - -var fn_popover_update = eventHandler.modules.popover.update; -eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) { - var $imagePopover = $popover.find('.note-image-popover'); - var $linkPopover = $popover.find('.note-link-popover'); - var $airPopover = $popover.find('.note-air-popover'); - - fn_popover_update.call(this, $popover, oStyle, isAirMode); - - if (oStyle.image) { - if (oStyle.image.parentNode.className.match(/(^|\s)media_iframe_video(\s|$)/i)) { - oStyle.image = oStyle.image.parentNode; - } - var alt = $(oStyle.image).attr("alt"); - - $imagePopover.find('.o_image_alt').text( (alt || "").replace(/"/g, '"') ).parent().toggle(oStyle.image.tagName === "IMG"); - $imagePopover.show(); - - // for video tag (non-void) we select the range over the tag, - // for other media types we get the first descendant leaf element - var target_node = oStyle.image; - if (!oStyle.image.className.match(/(^|\s)media_iframe_video(\s|$)/i)) { - target_node = dom.firstChild(target_node); - } - range.createFromNode(target_node).select(); - // save range on the editor so it is not lost if restored - eventHandler.modules.editor.saveRange(dom.makeLayoutInfo(target_node).editable()); - } else { - $(".note-control-selection").hide(); - } - - if (oStyle.image || (oStyle.range && (!oStyle.range.isCollapsed() || (oStyle.range.sc.tagName && !dom.isAnchor(oStyle.range.sc)))) || (oStyle.image && !$(oStyle.image).closest('a').length)) { - $linkPopover.hide(); - oStyle.anchor = false; - } - - if (oStyle.image || oStyle.anchor || (oStyle.range && !$(oStyle.range.sc).closest('.note-editable').length)) { - $airPopover.hide(); - } else { - $airPopover.show(); - } -}; - -var fn_handle_update = eventHandler.modules.handle.update; -eventHandler.modules.handle.update = function ($handle, oStyle, isAirMode) { - fn_handle_update.call(this, $handle, oStyle, isAirMode); - if (oStyle.image) { - $handle.find('.note-control-selection').hide(); - } -}; - -// Hack for image and link editor -function getImgTarget($editable) { - var $handle = $editable ? dom.makeLayoutInfo($editable).handle() : undefined; - return $(".note-control-selection", $handle).data('target'); -} -eventHandler.modules.editor.padding = function ($editable, sValue) { - var $target = $(getImgTarget($editable)); - var paddings = "small medium large xl".split(/\s+/); - $editable.data('NoteHistory').recordUndo(); - if (sValue.length) { - paddings.splice(paddings.indexOf(sValue),1); - $target.toggleClass('padding-'+sValue); - } - $target.removeClass("padding-" + paddings.join(" padding-")); -}; -eventHandler.modules.editor.resize = function ($editable, sValue) { - var $target = $(getImgTarget($editable)); - $editable.data('NoteHistory').recordUndo(); - var width = ($target.attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+)%/); - if (width) { - width = width[2]/100; - } - $target.css('width', (width !== sValue && sValue !== "auto") ? (sValue * 100) + '%' : ''); -}; -eventHandler.modules.editor.resizefa = function ($editable, sValue) { - var $target = $(getImgTarget($editable)); - $editable.data('NoteHistory').recordUndo(); - $target.attr('class', $target.attr('class').replace(/\s*fa-[0-9]+x/g, '')); - if (+sValue > 1) { - $target.addClass('fa-'+sValue+'x'); - } -}; -eventHandler.modules.editor.floatMe = function ($editable, sValue) { - var $target = $(getImgTarget($editable)); - $editable.data('NoteHistory').recordUndo(); - switch (sValue) { - case 'center': $target.toggleClass('d-block mx-auto').removeClass('float-right float-left'); break; - case 'left': $target.toggleClass('float-left').removeClass('float-right d-block mx-auto'); break; - case 'right': $target.toggleClass('float-right').removeClass('float-left d-block mx-auto'); break; - } -}; -eventHandler.modules.editor.imageShape = function ($editable, sValue) { - var $target = $(getImgTarget($editable)); - $editable.data('NoteHistory').recordUndo(); - $target.toggleClass(sValue); -}; - -eventHandler.modules.linkDialog.showLinkDialog = function ($editable, $dialog, linkInfo) { - $editable.data('range').select(); - $editable.data('NoteHistory').recordUndo(); - - var def = new $.Deferred(); - 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; -}; -eventHandler.modules.imageDialog.showImageDialog = function ($editable) { - var r = $editable.data('range'); - if (r.sc.tagName && r.sc.childNodes.length) { - r.sc = r.sc.childNodes[r.so]; - } - var media = $(r.sc).parents().addBack().filter(function (i, el) { - return dom.isImg(el); - })[0]; - core.bus.trigger('media_dialog_demand', { - $editable: $editable, - media: media, - options: { - lastFilters: ['background'], - onUpload: $editable.data('callbacks').onUpload, - noVideos: $editable.data('oe-model') === "mail.compose.message", - }, - }); - return new $.Deferred().reject(); -}; -$.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) { - var $editable = layoutInfo.editable(); - var $selection = layoutInfo.handle().find('.note-control-selection'); - core.bus.trigger('alt_dialog_demand', { - $editable: $editable, - media: $selection.data('target'), - }); -}; -$.summernote.pluginEvents.customColor = function (event, editor, layoutInfo, customColor) { - var defaultColor = event.target.dataset.color; - core.bus.trigger('color_picker_dialog_demand', { - color: defaultColor === 'rgba(0, 0, 0, 0)' ? 'rgb(255, 0, 0)' : defaultColor, - onSave: function (color) { - var $palettes = $(event.currentTarget).find('.note-custom-color-palette > .note-color-row') - .append(('<button type="button" class="note-color-btn" data-value="' + color + '" style="background-color:' + color + ';" />')); - $palettes.filter(':odd').find('button:not([data-event])').attr('data-event', 'backColor'); - $palettes.filter(':even').find('button:not([data-event])').attr('data-event', 'foreColor'); - if (customColor === 'foreColor') { - $(event.currentTarget).find('.note-fore-color-preview > button').css('border-bottom-color', color); - $.summernote.pluginEvents.foreColor(event, editor, layoutInfo, color); - } else { - $(event.currentTarget).find('.note-back-color-preview > button').css('border-bottom-color', color); - $.summernote.pluginEvents.backColor(event, editor, layoutInfo, color); - } - } - }); -}; -$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) { - var $editable = layoutInfo.editable(); - var $selection = layoutInfo.handle().find('.note-control-selection'); - core.bus.trigger('crop_image_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)); -}; -var fn_is_img = dom.isImg || function () {}; -dom.isImg = function (node) { - return fn_is_img(node) || dom.isImgFont(node) || (node && (node.nodeName === "IMG" || (node.className && node.className.match(/(^|\s)(media_iframe_video|o_image)(\s|$)/i)) )); -}; -var fn_is_forbidden_node = dom.isForbiddenNode || function () {}; -dom.isForbiddenNode = function (node) { - if (node.tagName === "BR") { - return false; - } - return fn_is_forbidden_node(node) || $(node).is(".media_iframe_video"); -}; -var fn_is_img_font = dom.isImgFont || function () {}; -dom.isImgFont = function (node) { - if (fn_is_img_font(node)) return true; - - var nodeName = node && node.nodeName.toUpperCase(); - var className = (node && node.className || ""); - if (node && (nodeName === "SPAN" || nodeName === "I") && className.length) { - var classNames = className.split(/\s+/); - for (var k=0; k<base.fontIcons.length; k++) { - if (_.intersection(base.fontIcons[k].alias, classNames).length) { - return true; - } - } - } - return false; -}; -var fn_is_font = dom.isFont; // re-overwrite font to include theme icons -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; - var $node = $(dom.node(rng.sc)); - if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) && - $node.hasClass("o_editable") && - !$node[0].children.length && - "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span label".toUpperCase().indexOf($node[0].nodeName) === -1) { - var p = $('<p><br/></p>')[0]; - $node.append( p ); - range.createFromNode(p.firstChild).select(); - } - return res; -}; - -function prettify_html(html) { - html = html.trim(); - var result = '', - level = 0, - get_space = function (level) { - var i = level, space = ''; - while (i--) space += ' '; - return space; - }, - reg = /^<\/?(a|span|font|u|em|i|strong|b)(\s|>)/i, - inline_level = Infinity, - tokens = _.compact(_.flatten(_.map(html.split(/</), function (value) { - value = value.replace(/\s+/g, ' ').split(/>/); - value[0] = /\S/.test(value[0]) ? '<' + value[0] + '>' : ''; - return value; - }))); - - // reduce => merge inline style + text - - for (var i = 0, l = tokens.length; i < l; i++) { - var token = tokens[i]; - var inline_tag = reg.test(token); - var inline = inline_tag || inline_level <= level; - - if (token[0] === '<' && token[1] === '/') { - if (inline_tag && inline_level === level) { - inline_level = Infinity; - } - level--; - } - - if (!inline && !/\S/.test(token)) { - continue; - } - if (!inline || (token[1] !== '/' && inline_level > level)) { - result += get_space(level); - } - - if (token[0] === '<' && token[1] !== '/') { - level++; - if (inline_tag && inline_level > level) { - inline_level = level; - } - } - - if (token.match(/^<(img|hr|br)/)) { - level--; - } - - // don't trim inline content (which could change appearance) - if (!inline) { - token = token.trim(); - } - - result += token.replace(/\s+/, ' '); - - if (inline_level > level) { - result += '\n'; - } - } - return result; -} - -/* - * This override when clicking on the 'Code View' button has two aims: - * - * - have our own code view implementation for FieldTextHtml - * - add an 'enable' paramater to call the function directly and allow us to - * disable (false) or enable (true) the code view mode. - */ -$.summernote.pluginEvents.codeview = function (event, editor, layoutInfo, enable) { - if (layoutInfo === undefined) { - return; - } - if (layoutInfo.toolbar) { - // if editor inline (FieldTextHtmlSimple) - var is_activated = $.summernote.eventHandler.modules.codeview.isActivated(layoutInfo); - if (is_activated === enable) { - return; - } - return eventHandler.modules.codeview.toggle(layoutInfo); - } else { - // if editor iframe (FieldTextHtml) - var $editor = layoutInfo.editor(); - var $textarea = $editor.prev('textarea'); - if ($textarea.is('textarea') === enable) { - return; - } - - if (!$textarea.length) { - // init and create texarea - var html = prettify_html($editor.prop("innerHTML")); - $editor.parent().css({ - 'position': 'absolute', - 'top': 0, - 'bottom': 0, - 'left': 0, - 'right': 0 - }); - $textarea = $('<textarea/>').css({ - 'margin': '0 -4px', - 'padding': '0 4px', - 'border': 0, - 'top': '51px', - 'left': '620px', - 'width': '100%', - 'font-family': 'sans-serif', - 'font-size': '13px', - 'height': '98%', - 'white-space': 'pre', - 'word-wrap': 'normal' - }).val(html).data('init', html); - $editor.before($textarea); - $editor.hide(); - } else { - // save changes - $editor.prop('innerHTML', $textarea.val().replace(/\s*\n\s*/g, '')).trigger('content_changed'); - $textarea.remove(); - $editor.show(); - } - } -}; - -// 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) { - initial_data.range = null; - if (event.shiftKey && event.keyCode >= 37 && event.keyCode <= 40 && !$(event.target).is("input, textarea, select")) { - var r = range.create(); - if (r) { - var rng = r.reRange(event.keyCode <= 38); - if (r !== rng) { - rng.select(); - } - } - } -} -function reRangeSelect(event, dx, dy) { - var r = range.create(); - if (!r || r.isCollapsed()) return; - - // check if the user move the caret on up or down - var data = r.reRange(dy < 0 || (dy === 0 && dx < 0)); - - if (data.sc !== r.sc || data.so !== r.so || data.ec !== r.ec || data.eo !== r.eo) { - setTimeout(function () { - data.select(); - $(data.sc.parentNode).closest('.note-popover'); - },0); - } - - $(data.sc).closest('.o_editable').data('range', r); - return r; -} -function summernote_mouseup(event) { - if ($(event.target).closest("#web_editor-top-navbar, .note-popover").length) { - return; - } - // don't rerange if simple click - if (initial_data.event) { - var dx = event.clientX - (event.shiftKey && initial_data.rect ? initial_data.rect.left : initial_data.event.clientX); - var dy = event.clientY - (event.shiftKey && initial_data.rect ? initial_data.rect.top : initial_data.event.clientY); - if (10 < Math.pow(dx, 2)+Math.pow(dy, 2)) { - reRangeSelect(event, dx, dy); - } - } - - if (!$(event.target).closest(".o_editable").length) { - return; - } - if (!initial_data.range || !event.shiftKey) { - setTimeout(function () { - initial_data.range = range.create(); - },0); - } -} -var remember_selection; -function summernote_mousedown(event) { - rte.history.splitNext(); - - var $editable = $(event.target).closest(".o_editable, .note-editor"); - var r; - - if (document.documentMode) { - summernote_ie_fix(event, function (node) { return node.tagName === "DIV" || node.tagName === "IMG" || (node.dataset && node.dataset.oeModel); }); - } else if (last_div && event.target !== last_div) { - if (last_div.tagName === "A") { - summernote_ie_fix(event, function (node) { return node.dataset && node.dataset.oeModel; }); - } else if ($editable.length) { - if (summernote_ie_fix(event, function (node) { return node.tagName === "A"; })) { - r = range.create(); - r.select(); - } - } - } - - // restore range if range lost after clicking on non-editable area - try { - r = range.create(); - } catch (e) { - // If this code is running inside an iframe-editor and that the range - // is outside of this iframe, this will fail as the iframe does not have - // the permission to check the outside content this way. In that case, - // we simply ignore the exception as it is as if there was no range. - return; - } - var editables = $(".o_editable[contenteditable], .note-editable[contenteditable]"); - var r_editable = editables.has((r||{}).sc).addBack(editables.filter((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)) { - remember_selection = range.create(dom.firstChild($editable[0]), 0); - } else if (!saved_editable.length) { - remember_selection = undefined; - } - if (remember_selection) { - try { - remember_selection.select(); - } catch (e) { - console.warn(e); - } - } - } else if (r_editable.length) { - remember_selection = r; - } - - initial_data.event = event; - - // keep selection when click with shift - if (event.shiftKey && $editable.length) { - if (initial_data.range) { - initial_data.range.select(); - } - var rect = r && r.getClientRects(); - initial_data.rect = rect && rect.length ? rect[0] : { top: 0, left: 0 }; - } -} - -function summernote_ie_fix(event, pred) { - var editable; - var div; - var node = event.target; - 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))) { - break; - } - if (node.className && node.className.indexOf('o_editable') !== -1) { - if (!div) { - div = node; - } - editable = node; - break; - } - node = node.parentNode; - } - - if (!editable) { - $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable"); - $(last_editable).attr("contentEditable", "true").prop("contentEditable", "true"); - last_div_change = null; - last_editable = null; - return; - } - - if (div === last_div) { - return; - } - - last_div = div; - - $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable"); - - if (last_editable !== editable) { - if ($(editable).is("[contentEditable='true']")) { - $(editable).removeAttr("contentEditable").removeProp("contentEditable"); - last_editable = editable; - } else { - last_editable = null; - } - } - if (!$(div).attr("contentEditable") && !$(div).is("[data-oe-type='many2one'], [data-oe-type='contact']")) { - $(div).attr("contentEditable", "true").prop("contentEditable", "true"); - last_div_change = div; - } else { - last_div_change = null; - } - return editable !== div ? div : null; -} - -var fn_attach = eventHandler.attach; -eventHandler.attach = function (oLayoutInfo, options) { - fn_attach.call(this, oLayoutInfo, options); - - oLayoutInfo.editor().on('dragstart', 'img', function (e) { e.preventDefault(); }); - $(document).on('mousedown', summernote_mousedown).on('mouseup', summernote_mouseup); - oLayoutInfo.editor().off('click').on('click', function (e) {e.preventDefault();}); // if the content editable is a link - - /** - * Open Media Dialog on double click on an image/video/icon. - * Shows a tooltip on click to say to the user he can double click. - */ - create_dblclick_feature("img, .media_iframe_video, i.fa, span.fa, a.o_image", function () { - eventHandler.modules.imageDialog.show(oLayoutInfo); - }); - - /** - * Open Link Dialog on double click on a link/button. - * Shows a tooltip on click to say to the user he can double click. - */ - create_dblclick_feature("a[href], a.btn, button.btn", function () { - eventHandler.modules.linkDialog.show(oLayoutInfo); - }); - - oLayoutInfo.editable().on('mousedown', function (e) { - if (dom.isImg(e.target) && dom.isContentEditable(e.target)) { - range.createFromNode(e.target).select(); - } - }); - $(document).on("keyup", reRangeSelectKey); - - var clone_data = false; - - if (options.model) { - oLayoutInfo.editable().data({'oe-model': options.model, 'oe-id': options.id}); - } - if (options.getMediaDomain) { - oLayoutInfo.editable().data('oe-media-domain', options.getMediaDomain); - } - - var $node = oLayoutInfo.editor(); - if ($node.data('oe-model') || $node.data('oe-translation-id')) { - $node.on('content_changed', function () { - var $nodes = $('[data-oe-model], [data-oe-translation-id]') - .filter(function () { return this !== $node[0];}); - - if ($node.data('oe-model')) { - $nodes = $nodes.filter('[data-oe-model="'+$node.data('oe-model')+'"]') - .filter('[data-oe-id="'+$node.data('oe-id')+'"]') - .filter('[data-oe-field="'+$node.data('oe-field')+'"]'); - } - if ($node.data('oe-translation-id')) $nodes = $nodes.filter('[data-oe-translation-id="'+$node.data('oe-translation-id')+'"]'); - if ($node.data('oe-type')) $nodes = $nodes.filter('[data-oe-type="'+$node.data('oe-type')+'"]'); - if ($node.data('oe-expression')) $nodes = $nodes.filter('[data-oe-expression="'+$node.data('oe-expression')+'"]'); - if ($node.data('oe-xpath')) $nodes = $nodes.filter('[data-oe-xpath="'+$node.data('oe-xpath')+'"]'); - if ($node.data('oe-contact-options')) $nodes = $nodes.filter('[data-oe-contact-options="'+$node.data('oe-contact-options')+'"]'); - - var nodes = $node.get(); - - if ($node.data('oe-type') === "many2one") { - $nodes = $nodes.add($('[data-oe-model]') - .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; }) - .filter('[data-oe-many2one-model="'+$node.data('oe-many2one-model')+'"]') - .filter('[data-oe-many2one-id="'+$node.data('oe-many2one-id')+'"]') - .filter('[data-oe-type="many2one"]')); - - $nodes = $nodes.add($('[data-oe-model]') - .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; }) - .filter('[data-oe-model="'+$node.data('oe-many2one-model')+'"]') - .filter('[data-oe-id="'+$node.data('oe-many2one-id')+'"]') - .filter('[data-oe-field="name"]')); - } - - if (!clone_data) { - clone_data = true; - $nodes.html(this.innerHTML); - clone_data = false; - } - }); - } - - var custom_toolbar = oLayoutInfo.toolbar ? oLayoutInfo.toolbar() : undefined; - var $toolbar = $(oLayoutInfo.popover()).add(custom_toolbar); - $('button[data-event="undo"], button[data-event="redo"]', $toolbar).attr('disabled', true); - - $(oLayoutInfo.editor()) - .add(oLayoutInfo.handle()) - .add(oLayoutInfo.popover()) - .add(custom_toolbar) - .on('click content_changed', function () { - $('button[data-event="undo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasUndo()); - $('button[data-event="redo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasRedo()); - }); - - function create_dblclick_feature(selector, callback) { - var show_tooltip = true; - - oLayoutInfo.editor().on("dblclick", selector, function (e) { - var $target = $(e.target); - if (!dom.isContentEditable($target)) { - // Prevent edition of non editable parts - return; - } - - show_tooltip = false; - callback(); - e.stopImmediatePropagation(); - }); - - oLayoutInfo.editor().on("click", selector, function (e) { - var $target = $(e.target); - if (!dom.isContentEditable($target)) { - // Prevent edition of non editable parts - return; - } - - show_tooltip = true; - setTimeout(function () { - if (!show_tooltip) return; - $target.tooltip({title: _t('Double-click to edit'), trigger: 'manuel', container: 'body'}).tooltip('show'); - setTimeout(function () { - $target.tooltip('dispose'); - }, 800); - }, 400); - }); - } -}; -var fn_detach = eventHandler.detach; -eventHandler.detach = function (oLayoutInfo, options) { - fn_detach.call(this, oLayoutInfo, options); - oLayoutInfo.editable().off('mousedown'); - oLayoutInfo.editor().off("dragstart"); - oLayoutInfo.editor().off('click'); - $(document).off('mousedown', summernote_mousedown); - $(document).off('mouseup', summernote_mouseup); - oLayoutInfo.editor().off("dblclick"); - $(document).off("keyup", reRangeSelectKey); -}; - -// Translation for odoo -$.summernote.lang.odoo = { - font: { - bold: _t('Bold'), - italic: _t('Italic'), - underline: _t('Underline'), - strikethrough: _t('Strikethrough'), - subscript: _t('Subscript'), - superscript: _t('Superscript'), - clear: _t('Remove Font Style'), - height: _t('Line Height'), - name: _t('Font Family'), - size: _t('Font Size') - }, - image: { - image: _t('File / Image'), - insert: _t('Insert Image'), - resizeFull: _t('Resize Full'), - resizeHalf: _t('Resize Half'), - resizeQuarter: _t('Resize Quarter'), - floatLeft: _t('Float Left'), - floatRight: _t('Float Right'), - floatNone: _t('Float None'), - dragImageHere: _t('Drag an image here'), - selectFromFiles: _t('Select from files'), - url: _t('Image URL'), - remove: _t('Remove Image') - }, - link: { - link: _t('Link'), - insert: _t('Insert Link'), - unlink: _t('Unlink'), - edit: _t('Edit'), - textToDisplay: _t('Text to display'), - url: _t('To what URL should this link go?'), - openInNewWindow: _t('Open in new window') - }, - video: { - video: _t('Video'), - videoLink: _t('Video Link'), - insert: _t('Insert Video'), - url: _t('Video URL?'), - providers: _t('(YouTube, Vimeo, Vine, Instagram, DailyMotion or Youku)') - }, - table: { - table: _t('Table') - }, - hr: { - insert: _t('Insert Horizontal Rule') - }, - style: { - style: _t('Style'), - normal: _t('Normal'), - blockquote: _t('Quote'), - pre: _t('Code'), - small: _t('Small'), - h1: _t('Header 1'), - h2: _t('Header 2'), - h3: _t('Header 3'), - h4: _t('Header 4'), - h5: _t('Header 5'), - h6: _t('Header 6') - }, - lists: { - unordered: _t('Unordered list'), - ordered: _t('Ordered list') - }, - options: { - help: _t('Help'), - fullscreen: _t('Full Screen'), - codeview: _t('Code View') - }, - paragraph: { - paragraph: _t('Paragraph'), - outdent: _t('Outdent'), - indent: _t('Indent'), - left: _t('Align left'), - center: _t('Align center'), - right: _t('Align right'), - justify: _t('Justify full') - }, - color: { - custom: _t('Custom Color'), - background: _t('Background Color'), - foreground: _t('Font Color'), - transparent: _t('Transparent'), - setTransparent: _t('None'), - }, - shortcut: { - shortcuts: _t('Keyboard shortcuts'), - close: _t('Close'), - textFormatting: _t('Text formatting'), - action: _t('Action'), - paragraphFormatting: _t('Paragraph formatting'), - documentStyle: _t('Document Style') - }, - history: { - undo: _t('Undo'), - redo: _t('Redo') - } -}; - -//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: - -/** - * @todo get rid of this. This has been implemented as a fix to be able to - * instantiate media, link and alt dialogs outside the main editor: in the - * simple HTML fields and forum textarea. - */ -var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, { - /** - * @constructor - */ - init: function (parent) { - mixins.EventDispatcherMixin.init.call(this); - this.setParent(parent); - - core.bus.on('alt_dialog_demand', this, this._onAltDialogDemand); - core.bus.on('color_picker_dialog_demand', this, this._onColorPickerDialogDemand); - core.bus.on('crop_image_dialog_demand', this, this._onCropImageDialogDemand); - core.bus.on('link_dialog_demand', this, this._onLinkDialogDemand); - core.bus.on('media_dialog_demand', this, this._onMediaDialogDemand); - }, - /** - * @override - */ - destroy: function () { - mixins.EventDispatcherMixin.destroy.call(this); - - core.bus.off('alt_dialog_demand', this, this._onAltDialogDemand); - core.bus.off('color_picker_dialog_demand', this, this._onColorPickerDialogDemand); - core.bus.off('crop_image_dialog_demand', this, this._onCropImageDialogDemand); - core.bus.off('link_dialog_demand', this, this._onLinkDialogDemand); - core.bus.off('media_dialog_demand', this, this._onMediaDialogDemand); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called when a demand to open a alt dialog is received on the bus. - * - * @private - * @param {Object} data - */ - _onAltDialogDemand: function (data) { - if (data.__alreadyDone) { - return; - } - data.__alreadyDone = true; - var altDialog = new weWidgets.AltDialog(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 color picker dialog is received on the bus. - * - * @private - * @param {Object} data - */ - _onColorPickerDialogDemand: function (data) { - if (data.__alreadyDone) { - return; - } - data.__alreadyDone = true; - var colorpicker = new ColorpickerDialog(this, { - defaultColor: data.color, - }); - if (data.onSave) { - colorpicker.on('colorpicker:saved', this, function (ev) { - data.onSave(ev.data.cssColor); - }); - } - colorpicker.open(); - }, - /** - * Called when a demand to open a crop dialog is received on the bus. - * - * @private - * @param {Object} data - */ - _onCropImageDialogDemand: function (data) { - if (data.__alreadyDone) { - return; - } - data.__alreadyDone = true; - var cropImageDialog = new weWidgets.CropImageDialog(this, - _.extend({ - res_model: data.$editable.data('oe-model'), - res_id: data.$editable.data('oe-id'), - }, data.options || {}), - data.$editable, - data.media - ); - if (data.onSave) { - cropImageDialog.on('save', this, data.onSave); - } - if (data.onCancel) { - cropImageDialog.on('cancel', this, data.onCancel); - } - cropImageDialog.open(); - }, - /** - * Called when a demand to open a link dialog is received on the bus. - * - * @private - * @param {Object} data - */ - _onLinkDialogDemand: function (data) { - if (data.__alreadyDone) { - return; - } - data.__alreadyDone = true; - 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) { - if (data.__alreadyDone) { - return; - } - data.__alreadyDone = true; - - var mediaDialog = new weWidgets.MediaDialog(this, - _.extend({ - res_model: data.$editable.data('oe-model'), - res_id: data.$editable.data('oe-id'), - domain: data.$editable.data('oe-media-domain'), - }, 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(); - }, -}); -/** - * @todo cannot do this without include because it would make a loop in the - * JS module dependencies otherwise. - */ -rte.Class.include({ - /** - * @override - */ - start: function () { - this._summernoteManager = new SummernoteManager(this); - return this._super.apply(this, arguments); - }, - /** - * @override - */ - cancel: function () { - this._super.apply(this, arguments); - this._summernoteManager.destroy(); - }, -}); -return SummernoteManager; -}); diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js deleted file mode 100644 index 36f5becce06..00000000000 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ /dev/null @@ -1,2436 +0,0 @@ -odoo.define('web_editor.summernote', function (require) { -'use strict'; - -var core = require('web.core'); -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; -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 renderer = $.summernote.renderer; -var options = $.summernote.options; - -// Browser-unify execCommand -var oldJustify = {}; -_.each(['Left', 'Right', 'Full', 'Center'], function (align) { - oldJustify[align] = editor['justify' + align]; - editor['justify' + align] = function ($editable, value) { - // Before calling the standard function, check all elements which have - // an 'align' attribute and mark them with their value - var $align = $editable.find('[align]'); - _.each($align, function (el) { - var $el = $(el); - $el.data('__align', $el.attr('align')); - }); - - // Call the standard function - oldJustify[align].apply(this, arguments); - - // Then: - - // Remove the text-align of elements which lost the 'align' attribute - var $newAlign = $editable.find('[align]'); - $align.not($newAlign).css('text-align', ''); - - // Transform the 'align' attribute into the 'text-align' css - // property for elements which received the 'align' attribute or whose - // 'align' attribute changed - _.each($newAlign, function (el) { - var $el = $(el); - - var oldAlignValue = $align.data('__align'); - var alignValue = $el.attr('align'); - if (oldAlignValue === alignValue) { - // If the element already had an 'align' attribute and that it - // did not changed, do nothing (compatibility) - return; - } - - $el.removeAttr('align'); - $el.css('text-align', alignValue); - - // Note the first step (removing the text-align of elemnts which - // lost the 'align' attribute) is kinda the same as this one, but - // this one handles the elements which have been edited with chrome - // or with this new system - $el.find('*').css('text-align', ''); - }); - - // Unmark the elements - $align.removeData('__align'); - }; -}); - - -// Add methods to summernote - -dom.hasContentAfter = function (node) { - var next; - if (dom.isEditable(node)) return; - while (node.nextSibling) { - next = node.nextSibling; - if (next.tagName || dom.isVisibleText(next) || dom.isBR(next)) return next; - node = next; - } -}; -dom.hasContentBefore = function (node) { - var prev; - if (dom.isEditable(node)) return; - while (node.previousSibling) { - prev = node.previousSibling; - if (prev.tagName || dom.isVisibleText(prev) || dom.isBR(prev)) return prev; - node = prev; - } -}; -dom.ancestorHaveNextSibling = function (node, pred) { - pred = pred || dom.hasContentAfter; - while (!dom.isEditable(node) && (!node.nextSibling || !pred(node))) { node = node.parentNode; } - return node; -}; -dom.ancestorHavePreviousSibling = function (node, pred) { - pred = pred || dom.hasContentBefore; - while (!dom.isEditable(node) && (!node.previousSibling || !pred(node))) { node = node.parentNode; } - return node; -}; -dom.nextElementSibling = function (node) { - while (node) { - node = node.nextSibling; - if (node && node.tagName) { - break; - } - } - return node; -}; -dom.previousElementSibling = function (node) { - while (node) { - node = node.previousSibling; - if (node && node.tagName) { - break; - } - } - return node; -}; -dom.lastChild = function (node) { - while (node.lastChild) { node = node.lastChild; } - return node; -}; -dom.firstChild = function (node) { - while (node.firstChild) { node = node.firstChild; } - return node; -}; -dom.lastElementChild = function (node, deep) { - node = deep ? dom.lastChild(node) : node.lastChild; - return !node || node.tagName ? node : dom.previousElementSibling(node); -}; -dom.firstElementChild = function (node, deep) { - node = deep ? dom.firstChild(node) : node.firstChild; - return !node || node.tagName ? node : dom.nextElementSibling(node); -}; -dom.isEqual = function (prev, cur) { - if (prev.tagName !== cur.tagName) { - return false; - } - if ((prev.attributes ? prev.attributes.length : 0) !== (cur.attributes ? cur.attributes.length : 0)) { - return false; - } - - function strip(text) { - return text && text.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' '); - } - var att, att2; - loop_prev: - for (var a in prev.attributes) { - att = prev.attributes[a]; - for (var b in cur.attributes) { - att2 = cur.attributes[b]; - if (att.name === att2.name) { - if (strip(att.value) !== strip(att2.value)) return false; - continue loop_prev; - } - } - return false; - } - return true; -}; -dom.hasOnlyStyle = function (node) { - for (var i = 0; i < node.attributes.length; i++) { - var attr = node.attributes[i]; - if (attr.attributeName !== 'style') { - return false; - } - } - return true; -}; -dom.hasProgrammaticStyle = function (node) { - var styles = ["float", "display", "position", "top", "left", "right", "bottom"]; - for (var i = 0; i < node.style.length; i++) { - var style = node.style[i]; - if (styles.indexOf(style) !== -1) { - return true; - } - } - return false; -}; -dom.mergeFilter = function (prev, cur, parent) { - // merge text nodes - if (prev && (dom.isText(prev) || (['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P'].indexOf(prev.tagName) !== -1 && prev !== cur.parentNode)) && dom.isText(cur)) { - return true; - } - if (prev && prev.tagName === "P" && dom.isText(cur)) { - return true; - } - if (prev && dom.isText(cur) && !dom.isVisibleText(cur) && (dom.isText(prev) || dom.isVisibleText(prev))) { - return true; - } - if (prev && !dom.isBR(prev) && dom.isEqual(prev, cur) && - ((prev.tagName && dom.getComputedStyle(prev).display === "inline" && - cur.tagName && dom.getComputedStyle(cur).display === "inline"))) { - return true; - } - if (dom.isEqual(parent, cur) && - ((parent.tagName && dom.getComputedStyle(parent).display === "inline" && - cur.tagName && dom.getComputedStyle(cur).display === "inline"))) { - return true; - } - if (parent && cur.tagName === "FONT" && (!cur.firstChild || (!cur.attributes.getNamedItem('style') && !cur.className.length))) { - return true; - } - // On backspace, webkit browsers create a <span> with a bunch of - // inline styles "remembering" where they come from. - // chances are we had e.g. - // <p>foo</p> - // <p>bar</p> - // merged the lines getting this in webkit - // <p>foo<span>bar</span></p> - if (parent && cur.tagName === "SPAN" && dom.hasOnlyStyle(cur) && !dom.hasProgrammaticStyle(cur)) { - return true; - } -}; -dom.doMerge = function (prev, cur) { - if (prev.tagName) { - if (prev.childNodes.length && !prev.textContent.match(/\S/) && dom.firstElementChild(prev) && dom.isBR(dom.firstElementChild(prev))) { - prev.removeChild(dom.firstElementChild(prev)); - } - if (cur.tagName) { - while (cur.firstChild) { - prev.appendChild(cur.firstChild); - } - cur.parentNode.removeChild(cur); - } else { - prev.appendChild(cur); - } - } else { - if (cur.tagName) { - var deep = cur; - while (deep.tagName && deep.firstChild) {deep = deep.firstChild;} - prev.appendData(deep.textContent); - cur.parentNode.removeChild(cur); - } else { - prev.appendData(cur.textContent); - cur.parentNode.removeChild(cur); - } - } -}; -dom.merge = function (node, begin, so, end, eo, mergeFilter, all) { - mergeFilter = mergeFilter || dom.mergeFilter; - var _merged = false; - var add = all || false; - - if (!begin) { - begin = node; - while (begin.firstChild) {begin = begin.firstChild;} - so = 0; - } else if (begin.tagName && begin.childNodes[so]) { - begin = begin.childNodes[so]; - so = 0; - } - if (!end) { - end = node; - while (end.lastChild) {end = end.lastChild;} - eo = end.textContent.length-1; - } else if (end.tagName && end.childNodes[so]) { - end = end.childNodes[so]; - so = 0; - } - - begin = dom.firstChild(begin); - if (dom.isText(begin) && so > begin.textContent.length) { - so = 0; - } - end = dom.firstChild(end); - if (dom.isText(end) && eo > end.textContent.length) { - eo = 0; - } - - function __merge(node) { - var merged = false; - var prev; - for (var k=0; k<node.childNodes.length; k++) { - var cur = node.childNodes[k]; - - if (cur === begin) { - if (!all) add = true; - } - - __merge(cur); - dom.orderClass(dom.node(cur)); - - if (!add || !cur) continue; - if (cur === end) { - if (!all) add = false; - } - - // create the first prev value - if (!prev) { - if (mergeFilter.call(dom, prev, cur, node)) { - prev = prev || cur.previousSibling; - dom.moveTo(cur, cur.parentNode, cur); - k--; - } else { - prev = cur; - } - continue; - } else if (mergeFilter.call(dom, null, cur, node)) { // merge with parent - prev = prev || cur.previousSibling; - dom.moveTo(cur, cur.parentNode, cur); - k--; - continue; - } - - // merge nodes - if (mergeFilter.call(dom, prev, cur, node)) { - var p = prev; - var c = cur; - // compute prev/end and offset - if (prev.tagName) { - if (cur.tagName) { - if (cur === begin) begin = prev; - if (cur === end) end = prev; - } - } else { - if (cur.tagName) { - var deep = cur; - while (deep.tagName && deep.lastChild) {deep = deep.lastChild;} - if (deep === begin) { - so += prev.textContent.length; - begin = prev; - } - if (deep === end) { - eo += prev.textContent.length; - end = prev; - } - } else { - // merge text nodes - if (cur === begin) { - so += prev.textContent.length; - begin = prev; - } - if (cur === end) { - eo += prev.textContent.length; - end = prev; - } - } - } - - dom.doMerge(p, c); - - merged = true; - k--; - continue; - } - - prev = cur; - } - - // an other loop to merge the new shibbing nodes - if (merged) { - _merged = true; - __merge(node); - } - } - if (node) { - __merge(node); - } - - return { - merged: _merged, - sc: begin, - ec: end, - so: so, - eo: eo - }; -}; -dom.autoMerge = function (target, previous) { - var node = dom.lastChild(target); - var nodes = []; - var temp; - - while (node) { - nodes.push(node); - temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node)); - if (temp) { - if (!dom.isText(node) && !dom.isMergable(node) && temp.tagName !== node.tagName) { - nodes = []; - } - break; - } - node = node.parentNode; - } - - while (nodes.length) { - node = nodes.pop(); - if (node && (temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node))) && - temp.tagName === node.tagName && - !dom.isText(node) && - dom.isMergable(node) && - !dom.isNotBreakable(node) && !dom.isNotBreakable(previous ? dom.previousElementSibling(node) : dom.nextElementSibling(node))) { - - if (previous) { - dom.doMerge(temp, node); - } else { - dom.doMerge(node, temp); - } - } - } -}; -dom.removeSpace = function (node, begin, so, end, eo) { - var removed = false; - var add = node === begin; - - if (node === begin && begin === end && dom.isBR(node)) { - return { - removed: removed, - sc: begin, - ec: end, - so: so, - eo: eo - }; - } - - (function __remove_space(node) { - if (!node) return; - var t_begin, t_end; - for (var k=0; k<node.childNodes.length; k++) { - var cur = node.childNodes[k]; - - if (cur === begin) add = true; - - if (cur.tagName && cur.tagName !== "SCRIPT" && cur.tagName !== "STYLE" && dom.getComputedStyle(cur).whiteSpace !== "pre") { - __remove_space(cur); - } - - if (!add) continue; - if (cur === end) add = false; - - // remove begin empty text node - if (node.childNodes.length > 1 && dom.isText(cur) && !dom.isVisibleText(cur)) { - removed = true; - if (cur === begin) { - t_begin = dom.hasContentBefore(dom.ancestorHavePreviousSibling(cur)); - if (t_begin) { - so = 0; - begin = dom.lastChild(t_begin); - } - } - if (cur === end) { - t_end = dom.hasContentAfter(dom.ancestorHaveNextSibling(cur)); - if (t_end) { - eo = 1; - end = dom.firstChild(t_end); - if (dom.isText(end)) { - eo = end.textContent.length; - } - } - } - cur.parentNode.removeChild(cur); - begin = dom.lastChild(begin); - end = dom.lastChild(end); - k--; - continue; - } - - // convert HTML space - if (dom.isText(cur)) { - var text; - var temp; - var _temp; - var exp1 = /[\t\n\r ]+/g; - var exp2 = /(?!([ ]|\u00A0)|^)\u00A0(?!([ ]|\u00A0)|$)/g; - if (cur === begin) { - temp = cur.textContent.substr(0, so); - _temp = temp.replace(exp1, ' ').replace(exp2, ' '); - so -= temp.length - _temp.length; - } - if (cur === end) { - temp = cur.textContent.substr(0, eo); - _temp = temp.replace(exp1, ' ').replace(exp2, ' '); - eo -= temp.length - _temp.length; - } - text = cur.textContent.replace(exp1, ' ').replace(exp2, ' '); - removed = removed || cur.textContent.length !== text.length; - cur.textContent = text; - } - } - })(node); - - return { - removed: removed, - sc: begin, - ec: end, - so: !dom.isBR(begin) && so > 0 ? so : 0, - eo: dom.isBR(end) ? 0 : eo - }; -}; -dom.removeBetween = function (sc, so, ec, eo, towrite) { - var text; - if (ec.tagName) { - if (ec.childNodes[eo]) { - ec = ec.childNodes[eo]; - eo = 0; - } else { - ec = dom.lastChild(ec); - eo = dom.nodeLength(ec); - } - } - if (sc.tagName) { - sc = sc.childNodes[so] || dom.firstChild(ec); - so = 0; - if (!dom.hasContentBefore(sc) && towrite) { - sc.parentNode.insertBefore(document.createTextNode('\u00A0'), sc); - } - } - if (!eo && sc !== ec) { - ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(ec)) || ec); - eo = ec.textContent.length; - } - - var ancestor = dom.commonAncestor(sc.tagName ? sc.parentNode : sc, ec.tagName ? ec.parentNode : ec) || dom.ancestor(sc, dom.isEditable); - - if (!dom.isContentEditable(ancestor)) { - return { - sc: sc, - so: so, - ec: sc, - eo: eo - }; - } - - if (ancestor.tagName) { - var ancestor_sc = sc; - var ancestor_ec = ec; - while (ancestor !== ancestor_sc && ancestor !== ancestor_sc.parentNode) { ancestor_sc = ancestor_sc.parentNode; } - while (ancestor !== ancestor_ec && ancestor !== ancestor_ec.parentNode) { ancestor_ec = ancestor_ec.parentNode; } - - - var node = dom.node(sc); - if (!dom.isNotBreakable(node) && !dom.isVoid(sc)) { - sc = dom.splitTree(ancestor_sc, {'node': sc, 'offset': so}); - } - var before = dom.hasContentBefore(dom.ancestorHavePreviousSibling(sc)); - - var after; - if (ec.textContent.slice(eo, Infinity).match(/\S|\u00A0/)) { - after = dom.splitTree(ancestor_ec, {'node': ec, 'offset': eo}); - } else { - after = dom.hasContentAfter(dom.ancestorHaveNextSibling(ec)); - } - - var nodes = dom.listBetween(sc, ec); - - var ancestor_first_last = function (node) { - return node === before || node === after; - }; - - for (var i=0; i<nodes.length; i++) { - if (!dom.ancestor(nodes[i], ancestor_first_last) && !$.contains(nodes[i], before) && !$.contains(nodes[i], after) && !dom.isEditable(nodes[i])) { - nodes[i].parentNode.removeChild(nodes[i]); - } - } - - if (dom.listAncestor(after).length <= dom.listAncestor(before).length) { - sc = dom.lastChild(before || ancestor); - so = dom.nodeLength(sc); - } else { - sc = dom.firstChild(after); - so = 0; - } - - if (dom.isVoid(node)) { - // we don't need to append a br - } else if (towrite && !node.firstChild && node.parentNode && !dom.isNotBreakable(node)) { - var br = $("<br/>")[0]; - node.appendChild(sc); - sc = br; - so = 0; - } else if (!ancestor.children.length && !ancestor.textContent.match(/\S|\u00A0/)) { - sc = $("<br/>")[0]; - so = 0; - $(ancestor).prepend(sc); - } else if (dom.isText(sc)) { - text = sc.textContent.replace(/[ \t\n\r]+$/, '\u00A0'); - so = Math.min(so, text.length); - sc.textContent = text; - } - } else { - text = ancestor.textContent; - ancestor.textContent = text.slice(0, so) + text.slice(eo, Infinity).replace(/^[ \t\n\r]+/, '\u00A0'); - } - - eo = so; - if (!dom.isBR(sc) && !dom.isVisibleText(sc) && !dom.isText(dom.hasContentBefore(sc)) && !dom.isText(dom.hasContentAfter(sc))) { - ancestor = dom.node(sc); - text = document.createTextNode('\u00A0'); - $(sc).before(text); - sc = text; - so = 0; - eo = 1; - } - - var parentNode = sc && sc.parentNode; - if (parentNode && sc.tagName === 'BR') { - sc = parentNode; - ec = parentNode; - } - - return { - sc: sc, - so: so, - ec: sc, - eo: eo - }; -}; -dom.indent = function (node) { - var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft'; - var margin = parseFloat(node.style[style] || 0)+1.5; - node.style[style] = margin + "em"; - return margin; -}; -dom.outdent = function (node) { - var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft'; - var margin = parseFloat(node.style[style] || 0)-1.5; - node.style[style] = margin > 0 ? margin + "em" : ""; - return margin; -}; -dom.scrollIntoViewIfNeeded = function (node) { - node = dom.node(node); - - var $span; - if (dom.isBR(node)) { - $span = $('<span/>').text('\u00A0'); - $(node).after($span); - node = $span[0]; - } - - if (node.scrollIntoViewIfNeeded) { - node.scrollIntoViewIfNeeded(false); - } else { - var offsetParent = node.offsetParent; - while (offsetParent) { - var elY = 0; - var elH = node.offsetHeight; - var parent = node; - - while (offsetParent && parent) { - elY += node.offsetTop; - - // get if a parent have a scrollbar - parent = node.parentNode; - while (parent !== offsetParent && - (parent.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(parent).overflowY) === -1)) { - parent = parent.parentNode; - } - node = parent; - - if (parent !== offsetParent) { - elY -= parent.offsetTop; - parent = null; - } - - offsetParent = node.offsetParent; - } - - if ((node.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(node).overflowY) !== -1) && - (node.scrollTop + node.clientHeight) < (elY + elH)) { - node.scrollTop = (elY + elH) - node.clientHeight; - } - } - } - - if ($span) { - $span.remove(); - } - - return; -}; -dom.moveTo = function (node, target, before) { - var nodes = []; - while (node.firstChild) { - nodes.push(node.firstChild); - if (before) { - target.insertBefore(node.firstChild, before); - } else { - target.appendChild(node.firstChild); - } - } - node.parentNode.removeChild(node); - return nodes; -}; -dom.isMergable = function (node) { - return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font".indexOf(node.tagName.toLowerCase()) !== -1; -}; -dom.isSplitable = function (node) { - return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a font".indexOf(node.tagName.toLowerCase()) !== -1; -}; -dom.isRemovableEmptyNode = function (node) { - return "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font span br".indexOf(node.tagName.toLowerCase()) !== -1; -}; -dom.isForbiddenNode = function (node) { - return node.tagName === "BR" || $(node).is(".fa, img"); -}; -/** - * @todo 'so' and 'eo' were added as a bugfix and are not given everytime. They - * however should be as the function may be wrong without them (for example, - * when asking the list between an element and its parent, as there is no path - * from the beginning of the former to the beginning of the later). - */ -dom.listBetween = function (sc, ec, so, eo) { - var nodes = []; - var ancestor = dom.commonAncestor(sc, ec); - dom.walkPoint({'node': sc, 'offset': so || 0}, {'node': ec, 'offset': eo || 0}, function (point) { - if (ancestor !== point.node || ancestor === sc || ancestor === ec) { - nodes.push(point.node); - } - }); - return list.unique(nodes); -}; -dom.isNotBreakable = function (node) { - // avoid triple click => crappy dom - return !dom.isText(node) && !dom.isBR(dom.firstChild(node)) && dom.isVoid(dom.firstChild(node)); -}; -dom.isContentEditable = function (node) { - return $(node).closest('[contenteditable]').prop('contenteditable') === 'true'; -}; -dom.isContentEditableFalse = function (node) { - return $(node).closest('[contenteditable]').prop('contenteditable') === 'false'; -}; -dom.isFont = function (node) { - var nodeName = node && node.nodeName.toUpperCase(); - return node && (nodeName === "FONT" || - (nodeName === "SPAN" && ( - node.className.match(/(^|\s)fa(\s|$)/i) || - node.className.match(/(^|\s)(text|bg)-/i) || - (node.attributes.style && node.attributes.style.value.match(/(^|\s)(color|background-color|font-size):/i)))) ); -}; -dom.isVisibleText = function (textNode) { - return !!textNode.textContent.match(/\S|\u00A0/); -}; -var old_isVisiblePoint = dom.isVisiblePoint; -dom.isVisiblePoint = function (point) { - return point.node.nodeType !== 8 && old_isVisiblePoint.apply(this, arguments); -}; -dom.orderStyle = function (node) { - var style = node.getAttribute('style'); - if (!style) return null; - style = style.replace(/[\s\n\r]+/, ' ').replace(/^ ?;? ?| ?;? ?$/g, '').replace(/ ?; ?/g, ';'); - if (!style.length) { - node.removeAttribute("style"); - return null; - } - style = style.split(";"); - style.sort(); - style = style.join("; ")+";"; - node.setAttribute('style', style); - return style; -}; -dom.orderClass = function (node) { - var className = node.getAttribute && node.getAttribute('class'); - if (!className) return null; - className = className.replace(/[\s\n\r]+/, ' ').replace(/^ | $/g, '').replace(/ +/g, ' '); - if (!className.length) { - node.removeAttribute("class"); - return null; - } - className = className.split(" "); - className.sort(); - className = className.join(" "); - node.setAttribute('class', className); - return className; -}; -dom.node = function (node) { - return dom.isText(node) ? node.parentNode : node; -}; -dom.moveContent = function (from, to) { - if (from === to) { - return; - } - if (from.parentNode === to) { - while (from.lastChild) { - dom.insertAfter(from.lastChild, from); - } - } else { - while (from.firstChild && from.firstChild !== to) { - to.appendChild(from.firstChild); - } - } -}; -dom.getComputedStyle = function (node) { - return node.nodeType === Node.COMMENT_NODE ? {} : window.getComputedStyle(node); -}; - -//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: - -range.WrappedRange.prototype.reRange = function (keep_end, isNotBreakable) { - var sc = this.sc; - var so = this.so; - var ec = this.ec; - var eo = this.eo; - isNotBreakable = isNotBreakable || dom.isNotBreakable; - - // search the first snippet editable node - var start = keep_end ? ec : sc; - while (start) { - if (isNotBreakable(start, sc, so, ec, eo)) { - break; - } - start = start.parentNode; - } - - // check if the end caret have the same node - var lastFilterEnd; - var end = keep_end ? sc : ec; - while (end) { - if (start === end) { - break; - } - if (isNotBreakable(end, sc, so, ec, eo)) { - lastFilterEnd = end; - } - end = end.parentNode; - } - if (lastFilterEnd) { - end = lastFilterEnd; - } - if (!end) { - end = document.getElementsByTagName('body')[0]; - } - - // if same node, keep range - if (start === end || !start) { - return this; - } - - // reduce or extend the range to don't break a isNotBreakable area - if ($.contains(start, end)) { - - if (keep_end) { - sc = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || sc); - so = sc.textContent.length; - } else if (!eo) { - ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || ec); - eo = ec.textContent.length; - } else { - ec = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(end)) || ec); - eo = 0; - } - } else { - - if (keep_end) { - sc = dom.firstChild(start); - so = 0; - } else { - ec = dom.lastChild(start); - eo = ec.textContent.length; - } - } - - return new range.WrappedRange(sc, so, ec, eo); -}; -/** - * Returns the image the range is in or matches (if any, false otherwise). - * - * @todo this implementation may not cover all corner cases but should do the - * trick for all reproductible ones - * @returns {DOMElement|boolean} - */ -range.WrappedRange.prototype.isOnImg = function () { - // If not a selection but a cursor position, just check if a point's - // ancestor is an image or not - if (this.sc === this.ec && this.so === this.eo) { - return dom.ancestor(this.sc, dom.isImg); - } - - var startPoint = {node: this.sc, offset: this.so}; - var endPoint = {node: this.ec, offset: this.eo}; - - var nb = 0; - var image; - var textNode; - dom.walkPoint(startPoint, endPoint, function (point) { - // If the element has children (not a text node and not empty node), - // the element cannot be considered as selected (these children will - // be processed to determine that) - if (dom.hasChildren(point.node)) { - return; - } - - // Check if an ancestor of the current point is an image - var pointImg = dom.ancestor(point.node, dom.isImg); - var isText = dom.isText(point.node); - - // Check if a visible element is selected, i.e. - // - If an ancestor of the current is an image we did not see yet - // - If the point is not in a br or a text (so a node with no children) - // - If the point is in a non empty text node we already saw - if (pointImg ? - (image !== pointImg) : - ((!dom.isBR(point.node) && !isText) || (textNode === point.node && point.node.textContent.match(/\S|\u00A0/)))) { - nb++; - } - - // If an ancestor of the current point is an image, then save it as the - // image we are looking for - if (pointImg) { - image = pointImg; - } - // If the current point is a text node save it as the last text node - // seen (if we see it again, this might mean it is selected) - if (isText) { - textNode = point.node; - } - }); - - return nb === 1 && image; -}; -range.WrappedRange.prototype.deleteContents = function (towrite) { - if (this.sc === this.ec && this.so === this.eo) { - return this; - } - - var r; - var image = this.isOnImg(); - if (image) { - // If the range matches/is in an image, then the image is to be removed - // and the cursor moved to its previous position - var parentNode = image.parentNode; - var index = _.indexOf(parentNode.childNodes, image); - parentNode.removeChild(image); - r = new range.WrappedRange(parentNode, index, parentNode, index); - } else { - r = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite); - } - - $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler - return new range.WrappedRange(r.sc, r.so, r.ec, r.eo); -}; -range.WrappedRange.prototype.clean = function (mergeFilter, all) { - var node = dom.node(this.sc === this.ec ? this.sc : this.commonAncestor()); - node = node || $(this.sc).closest('[contenteditable]')[0]; - if (node.childNodes.length <=1) { - return this; - } - - var merge = dom.merge(node, this.sc, this.so, this.ec, this.eo, mergeFilter, all); - var rem = dom.removeSpace(node.parentNode, merge.sc, merge.so, merge.ec, merge.eo); - - if (merge.merged || rem.removed) { - return range.create(rem.sc, rem.so, rem.ec, rem.eo); - } - return this; -}; -range.WrappedRange.prototype.remove = function (mergeFilter) { -}; -range.WrappedRange.prototype.isOnCellFirst = function () { - var node = dom.ancestor(this.sc, function (node) {return ["LI", "DIV", "TD","TH"].indexOf(node.tagName) !== -1;}); - return node && ["TD","TH"].indexOf(node.tagName) !== -1; -}; -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) { - return memo + '<a data-event="fontSize" href="#" class="dropdown-item" data-value="' + v + '">' + - '<i class="fa fa-check"></i> ' + v + - '</a>'; - }, ''); - - var sLabel = '<span class="note-current-fontsize">11</span>'; - return renderer.getTemplate().button(sLabel, { - title: lang.font.size, - dropdown: '<div class="dropdown-menu">' + items + '</div>' - }); -}; - -renderer.tplButtonInfo.color = function (lang, options) { - var foreColorButtonLabel = '<i class="' + options.iconPrefix + options.icons.color.recent + '"></i>'; - var backColorButtonLabel = '<i class="' + options.iconPrefix + 'paint-brush"></i>'; - // TODO Remove recent color button if possible. - // It is still put to avoid JS errors when clicking other buttons as the - // editor still expects it to exist. - var recentColorButton = renderer.getTemplate().button(foreColorButtonLabel, { - className: 'note-recent-color d-none', - title: lang.color.foreground, - event: 'color', - value: '{"backColor":"#B35E9B"}' - }); - var foreColorItems = [ - '<li><div class="btn-group flex-column">', - '<div class="note-color-palette" data-target-event="foreColor"></div>', - '<h6 class="note-custom-color mt8" data-event="customColor" data-value="foreColor" title="' + lang.color.custom + '">', - lang.color.custom + '</h6>', - '<div class="note-custom-color-palette" data-target-event="foreColor"></div>', - '</div></li>', - ]; - var backColorItems = [ - '<li><div class="btn-group flex-column">', - '<div class="note-color-reset" data-event="backColor" data-value="inherit" title="' + lang.color.transparent + '">', - lang.color.setTransparent + '</div>', - '<div class="note-color-palette" data-target-event="backColor"></div>', - '<h6 class="note-custom-color mt8" data-event="customColor" data-value="backColor" title="' + lang.color.custom + '">', - lang.color.custom + '</h6>', - '<div class="note-custom-color-palette" data-target-event="backColor"></div>', - '</div></li>', - ]; - var foreColorButton = renderer.getTemplate().button(foreColorButtonLabel, { - className: 'note-fore-color-preview mx-1', - title: lang.color.foreground, - dropdown: renderer.getTemplate().dropdown(foreColorItems) - }); - var backColorButton = renderer.getTemplate().button(backColorButtonLabel, { - className: 'note-back-color-preview mx-1', - title: lang.color.background, - dropdown: renderer.getTemplate().dropdown(backColorItems) - }); - return recentColorButton + foreColorButton + backColorButton; -}, - -//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: - -key.nameFromCode[46] = 'DELETE'; -key.nameFromCode[27] = 'ESCAPE'; - -options.keyMap.pc['BACKSPACE'] = 'backspace'; -options.keyMap.pc['DELETE'] = 'delete'; -options.keyMap.pc['ENTER'] = 'enter'; -options.keyMap.pc['ESCAPE'] = 'cancel'; -options.keyMap.mac['SHIFT+TAB'] = 'untab'; -options.keyMap.pc['UP'] = 'up'; -options.keyMap.pc['DOWN'] = 'down'; - -options.keyMap.mac['BACKSPACE'] = 'backspace'; -options.keyMap.mac['DELETE'] = 'delete'; -options.keyMap.mac['ENTER'] = 'enter'; -options.keyMap.mac['ESCAPE'] = 'cancel'; -options.keyMap.mac['UP'] = 'up'; -options.keyMap.mac['DOWN'] = 'down'; - -options.styleTags = ['p', 'pre', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote']; - -$.summernote.pluginEvents.insertTable = function (event, editor, layoutInfo, sDim) { - var $editable = layoutInfo.editable(); - var dimension = sDim.split('x'); - var r = range.create(); - if (!r) return; - r = r.deleteContents(true); - - var table = editor.table.createTable(dimension[0], dimension[1]); - var parent = r.sc; - while (dom.isText(parent.parentNode) || dom.isRemovableEmptyNode(parent.parentNode)) { - parent = parent.parentNode; - } - var node = dom.splitTree(parent, {'node': r.sc, 'offset': r.so}) || r.sc; - node.parentNode.insertBefore(table, node); - - if ($(node).text() === '' || node.textContent === '\u00A0') { - node.parentNode.removeChild(node); - } - - editor.afterCommand($editable); - event.preventDefault(); - return false; -}; -$.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable, 'tab'); - var r = range.create(); - outdent = outdent || false; - event.preventDefault(); - - if (r && (dom.ancestor(r.sc, dom.isCell) || dom.ancestor(r.ec, dom.isCell))) { - if (r.isCollapsed() && r.isOnCell() && r.isOnCellFirst()) { - var td = dom.ancestor(r.sc, dom.isCell); - if (!outdent && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode)) { - var last = dom.lastChild(td); - range.create(last, dom.nodeLength(last), last, dom.nodeLength(last)).select(); - $.summernote.pluginEvents.enter(event, editor, layoutInfo); - } else if (outdent && !dom.previousElementSibling(td) && !$(td.parentNode).text().match(/\S/)) { - $.summernote.pluginEvents.backspace(event, editor, layoutInfo); - } else { - editor.table.tab(r, outdent); - } - } else { - $.summernote.pluginEvents.indent(event, editor, layoutInfo, outdent); - } - } else if (r && r.isCollapsed()) { - if (!r.sc.textContent.slice(0,r.so).match(/\S/) && r.isOnList()) { - if (outdent) { - $.summernote.pluginEvents.outdent(event, editor, layoutInfo); - } else { - $.summernote.pluginEvents.indent(event, editor, layoutInfo); - } - } else { - var next; - if (!outdent) { - if (dom.isText(r.sc)) { - next = r.sc.splitText(r.so); - } else { - next = document.createTextNode(''); - $(r.sc.childNodes[r.so]).before(next); - } - editor.typing.insertTab($editable, r, options.tabsize); - r = range.create(next, 0, next, 0); - r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true); - range.create(r.sc, r.so, r.ec, r.eo).select(); - } 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); - if (r.sc.splitText) { - 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(); - } - } - } - } - return false; -}; -$.summernote.pluginEvents.untab = function (event, editor, layoutInfo) { - return $.summernote.pluginEvents.tab(event, editor, layoutInfo, true); -}; -$.summernote.pluginEvents.up = function (event, editor, layoutInfo) { - var r = range.create(); - var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc); - if (!r.isOnCell()) { - return; - } - // check if an ancestor between node and cell has content before - var ancestor = dom.ancestor(node, function (ancestorNode) { - return dom.hasContentBefore(ancestorNode) || dom.isCell(ancestorNode); - }); - if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentBefore(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentBefore(dom.hasContentBefore(ancestor)))) { - return; - } - event.preventDefault(); - var td = dom.ancestor(r.sc, dom.isCell); - var tr = td.parentNode; - var target = tr.previousElementSibling && tr.previousElementSibling.children[_.indexOf(tr.children, td)]; - if (!target) { - target = (dom.ancestorHavePreviousSibling(tr) || tr).previousSibling; - } - if (target) { - range.create(dom.lastChild(target), dom.lastChild(target).textContent.length).select(); - } -}; -$.summernote.pluginEvents.down = function (event, editor, layoutInfo) { - var r = range.create(); - var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc); - if (!r.isOnCell()) { - return; - } - // check if an ancestor between node and cell has content after - var ancestor = dom.ancestor(node, function (ancestorNode) { - return dom.hasContentAfter(ancestorNode) || dom.isCell(ancestorNode); - }); - if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentAfter(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentAfter(dom.hasContentAfter(ancestor)))) { - return; - } - event.preventDefault(); - var td = dom.ancestor(r.sc, dom.isCell); - var tr = td.parentNode; - var target = tr.nextElementSibling && tr.nextElementSibling.children[_.indexOf(tr.children, td)]; - if (!target) { - target = (dom.ancestorHaveNextSibling(tr) || tr).nextSibling; - } - if (target) { - range.create(dom.firstChild(target), 0).select(); - } -}; -$.summernote.pluginEvents.enter = function (event, editor, layoutInfo) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable, 'enter'); - - var r = range.create(); - if (!r.isContentEditable()) { - event.preventDefault(); - return false; - } - if (!r.isCollapsed()) { - r = r.deleteContents(); - r.select(); - } - - var br = $("<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)) { - node = r.sc.previousSibling; - while (!contentBefore && node && dom.isText(node)) { - contentBefore = dom.isVisibleText(node); - node = node.previousSibling; - } - } - - 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/))) { - $node = $(td.parentNode); - $clone = $node.clone(); - $clone.children().html(dom.blank); - $node.after($clone); - node = dom.firstElementChild($clone[0]) || $clone[0]; - range.create(node, 0, node, 0).select(); - dom.scrollIntoViewIfNeeded(br); - event.preventDefault(); - return false; - } - - var last = node; - while (node && dom.isSplitable(node) && !dom.isList(node)) { - last = node; - node = node.parentNode; - } - - if (last === node && !dom.isBR(node)) { - node = r.insertNode(br, true); - if (isFormatNode(last.firstChild) && $(last).closest(options.styleTags.join(',')).length) { - dom.moveContent(last.firstChild, last); - last.removeChild(last.firstChild); - } - do { - node = dom.hasContentAfter(node); - } while (node && dom.isBR(node)); - - // create an other br because the user can't see the new line with only br in a block - if (!node && (!br.nextElementSibling || !dom.isBR(br.nextElementSibling))) { - $(br).before($("<br/>")[0]); - } - node = br.nextSibling || br; - } else if (last === node && dom.isBR(node)) { - $(node).after(br); - node = br; - } else if (!r.so && r.isOnList() && !r.sc.textContent.length && !dom.ancestor(r.sc, dom.isLi).nextElementSibling) { - // double enter on the end of a list = new line out of the list - $('<p></p>').append(br).insertAfter(dom.ancestor(r.sc, dom.isList)); - node = br; - } else if (dom.isBR(exist) && $(r.sc).closest('blockquote, pre').length && !dom.hasContentAfter($(exist.parentNode).closest('blockquote *, pre *').length ? exist.parentNode : exist)) { - // double enter on the end of a blockquote & pre = new line out of the list - $('<p></p>').append(br).insertAfter($(r.sc).closest('blockquote, pre')); - node = br; - } else if (dom.isEditable(dom.node(r.sc))) { - // if we are directly in an editable, only SHIFT + ENTER should add a newline - node = null; - } else if (last === r.sc) { - if (dom.isBR(last)) { - last = last.parentNode; - } - $node = $(last); - $clone = $node.clone().text(""); - $node.after($clone); - node = dom.node(dom.firstElementChild($clone[0]) || $clone[0]); - $(node).html(br); - node = br; - } else { - node = dom.splitTree(last, {'node': r.sc, 'offset': r.so}) || r.sc; - if (!contentBefore) { - var cur = dom.node(dom.lastChild(node.previousSibling)); - if (!dom.isBR(cur)) { - $(cur).html(br); - } - } - if (!dom.isVisibleText(node)) { - node = dom.firstChild(node); - $(dom.node( dom.isBR(node) ? node.parentNode : node )).html(br); - node = br; - } - } - - if (node) { - node = dom.firstChild(node); - if (dom.isBR(node)) { - range.createFromNode(node).select(); - } else { - range.create(node,0).select(); - } - dom.scrollIntoViewIfNeeded(node); - } - event.preventDefault(); - return false; -}; -$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable, "visible"); - - var r = range.create(); - if (!r) return; - - if (!r.isCollapsed()) { - if ((dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) && dom.node(r.sc) !== dom.node(r.ec)) { - remove_table_content(r); - r = range.create(r.ec, 0); - } - r.select(); - } - - // don't write in forbidden tag (like span for font awsome) - var node = dom.firstChild(r.sc.tagName && r.so ? r.sc.childNodes[r.so] || r.sc : r.sc); - while (node.parentNode) { - if (dom.isForbiddenNode(node)) { - var text = node.previousSibling; - if (text && dom.isText(text) && dom.isVisibleText(text)) { - range.create(text, text.textContent.length, text, text.textContent.length).select(); - } else { - text = node.parentNode.insertBefore(document.createTextNode( "." ), node); - range.create(text, 1, text, 1).select(); - setTimeout(function () { - var text = range.create().sc; - text.textContent = text.textContent.replace(/^./, ''); - range.create(text, text.textContent.length, text, text.textContent.length).select(); - },0); - } - break; - } - node = node.parentNode; - } - - return true; -}; - -function remove_table_content(r) { - var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo); - if (dom.isText(r.sc)) { - r.sc.textContent = r.sc.textContent.slice(0, r.so); - } - if (dom.isText(r.ec)) { - r.ec.textContent = r.ec.textContent.slice(r.eo); - } - for (var i in nodes) { - var node = nodes[i]; - if (node === r.sc || node === r.ec || $.contains(node, r.sc) || $.contains(node, r.ec)) { - continue; - } else if (dom.isCell(node)) { - $(node).html("<br/>"); - } else if (node.parentNode) { - do { - var parent = node.parentNode; - parent.removeChild(node); - node = parent; - } while (!dom.isVisibleText(node) && !dom.firstElementChild(node) && - !dom.isCell(node) && - node.parentNode && !$(node.parentNode).hasClass('o_editable')); - } - } - return false; -} - -$.summernote.pluginEvents.delete = function (event, editor, layoutInfo) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable, "delete"); - - var r = range.create(); - if (!r) return; - if (!r.isContentEditable()) { - event.preventDefault(); - return false; - } - if (!r.isCollapsed()) { - if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) { - remove_table_content(r); - range.create(r.ec, 0).select(); - } else { - r = r.deleteContents(); - r.select(); - } - event.preventDefault(); - return false; - } - - var target = r.ec; - var offset = r.eo; - if (target.tagName && target.childNodes[offset]) { - target = target.childNodes[offset]; - offset = 0; - } - - var node = dom.node(target); - var data = dom.merge(node, target, offset, target, offset, null, true); - data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo); - r = range.create(data.sc, data.so); - r.select(); - target = r.sc; - offset = r.so; - - while (!dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;} - - var contentAfter = target.textContent.slice(offset,Infinity).match(/\S|\u00A0/); - 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; - if (!dom.isImg(node)) { - node = dom.hasContentAfter(node); - } - while (dom.isImg(node)) { - parent = node.parentNode; - index = dom.position(node); - if (index>0) { - next = node.previousSibling; - r = range.create(next, next.textContent.length); - } else { - r = range.create(parent, 0); - } - if (!dom.hasContentAfter(node) && !dom.hasContentBefore(node)) { - parent.appendChild($('<br/>')[0]); - } - parent.removeChild(node); - node = parent; - r.select(); - } - } - // empty tag - else if (!content.length && target.tagName && dom.isRemovableEmptyNode(dom.isBR(target) ? target.parentNode : target)) { - if (node === $editable[0] || $.contains(node, $editable[0])) { - event.preventDefault(); - return false; - } - var before = false; - next = dom.hasContentAfter(dom.ancestorHaveNextSibling(node)); - if (!dom.isContentEditable(next)) { - before = true; - next = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node)); - } - dom.removeSpace(next.parentNode, next, 0, next, 0); // clean before jump for not select invisible space between 2 tag - next = dom.firstChild(next); - node.parentNode.removeChild(node); - range.create(next, before ? next.textContent.length : 0).select(); - } - // normal feature if same tag and not the end - else if (contentAfter) { - return true; - } - // merge with the next text node - else if (dom.isText(target) && (temp = dom.hasContentAfter(target)) && dom.isText(temp)) { - return true; - } - //merge with the next block - else if ((temp = dom.ancestorHaveNextSibling(target)) && - !r.isOnCell() && - dom.isMergable(temp) && - dom.isMergable(temp2 = dom.hasContentAfter(temp)) && - temp.tagName === temp2.tagName && - (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li - !dom.isNotBreakable(temp) && - !dom.isNotBreakable(temp2)) { - dom.autoMerge(target, false); - next = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(target))); - if (dom.isBR(next)) { - if (dom.position(next) === 0) { - range.create(next.parentNode, 0).select(); - } - else { - range.create(next.previousSibling, next.previousSibling.textContent.length).select(); - } - next.parentNode.removeChild(next); - } else { - range.create(next, 0).select(); - } - } - // jump to next node for delete - else if ((temp = dom.ancestorHaveNextSibling(target)) && (temp2 = dom.hasContentAfter(temp)) && dom.isContentEditable(temp2)) { - - dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag - temp2 = dom.firstChild(temp2); - - r = range.create(temp2, 0); - r.select(); - - if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) { - if (dom.isText(temp2)) { - temp2.textContent = temp2.textContent.replace(/^\s*\S/, ''); - } else { - $.summernote.pluginEvents.delete(event, editor, layoutInfo); - } - } - } - - $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler - event.preventDefault(); - return false; -}; -$.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable, "backspace"); - - var r = range.create(); - if (!r) return; - if (!r.isContentEditable()) { - event.preventDefault(); - return false; - } - if (!r.isCollapsed()) { - if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) { - remove_table_content(r); - range.create(r.sc, dom.nodeLength(r.sc)).select(); - } else { - r = r.deleteContents(); - r.select(); - } - event.preventDefault(); - return false; - } - - var target = r.sc; - var offset = r.so; - if (target.tagName && target.childNodes[offset]) { - target = target.childNodes[offset]; - offset = 0; - } - - var node = dom.node(target); - var data = dom.merge(node, target, offset, target, offset, null, true); - data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo); - r = dom.isVoid(data.sc) ? range.createFromNode(data.sc) : range.create(data.sc, data.so); - r.select(); - target = r.sc; - offset = r.so; - if (target.tagName && target.childNodes[offset]) { - target = target.childNodes[offset]; - offset = 0; - node = dom.node(target); - } - - while (node.parentNode && !dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;} - - var contentBefore = target.textContent.slice(0,offset).match(/\S|\u00A0/); - 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)))) { - if (!dom.isImg(node)) { - node = dom.hasContentBefore(node); - } - range.createFromNode(node).select(); - $.summernote.pluginEvents.delete(event, editor, layoutInfo); - } - // table tr td - else if (r.isOnCell() && !offset && (target === (temp = dom.ancestor(target, dom.isCell)) || target === temp.firstChild || (dom.isText(temp.firstChild) && !dom.isVisibleText(temp.firstChild) && target === temp.firstChild.nextSibling))) { - if (dom.previousElementSibling(temp)) { - var td = dom.previousElementSibling(temp); - node = td.lastChild || td; - } else { - var tr = temp.parentNode; - var prevTr = dom.previousElementSibling(tr); - if (!$(temp.parentNode).text().match(/\S|\u00A0/)) { - if (prevTr) { - node = dom.lastChild(dom.lastElementChild(prevTr)); - } else { - node = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(tr)) || $editable.get(0)); - } - $(tr).empty(); - if (!$(tr).closest('table').has('td, th').length) { - $(tr).closest('table').remove(); - } - $(tr).remove(); - range.create(node, node.textContent.length, node, node.textContent.length).select(); - } else { - node = dom.lastElementChild(prevTr).lastChild || dom.lastElementChild(prevTr); - } - } - if (dom.isBR(node)) { - range.createFromNode(node).select(); - } else { - range.create(node, dom.nodeLength(node)).select(); - } - } - // empty tag - else if (!content.length && target.tagName && dom.isRemovableEmptyNode(target)) { - if (node === $editable[0] || $.contains(node, $editable[0])) { - event.preventDefault(); - return false; - } - var before = true; - prev = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node)); - if (!dom.isContentEditable(prev)) { - before = false; - prev = dom.hasContentAfter(dom.ancestorHaveNextSibling(node)); - } - dom.removeSpace(prev.parentNode, prev, 0, prev, 0); // clean before jump for not select invisible space between 2 tag - prev = dom.lastChild(prev); - node.parentNode.removeChild(node); - range.createFromNode(prev).select(); - range.create(prev, before ? prev.textContent.length : 0).select(); - } - // normal feature if same tag and not the begin - else if (contentBefore) { - return true; - } - // merge with the previous text node - else if (dom.isText(target) && (temp = dom.hasContentBefore(target)) && (dom.isText(temp) || dom.isBR(temp))) { - return true; - } - //merge with the previous block - else if ((temp = dom.ancestorHavePreviousSibling(target)) && - dom.isMergable(temp) && - dom.isMergable(temp2 = dom.hasContentBefore(temp)) && - temp.tagName === temp2.tagName && - (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li - !dom.isNotBreakable(temp) && - !dom.isNotBreakable(temp2)) { - prev = dom.firstChild(target); - dom.autoMerge(target, true); - range.create(prev, 0).select(); - } - // jump to previous node for delete - else if ((temp = dom.ancestorHavePreviousSibling(target)) && (temp2 = dom.hasContentBefore(temp)) && dom.isContentEditable(temp2)) { - - dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag - temp2 = dom.lastChild(temp2); - - r = range.create(temp2, temp2.textContent.length, temp2, temp2.textContent.length); - r.select(); - - if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) { - if (dom.isText(temp2)) { - temp2.textContent = temp2.textContent.replace(/\S\s*$/, ''); - } else { - $.summernote.pluginEvents.backspace(event, editor, layoutInfo); - } - } - } - - 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); - } - - event.preventDefault(); - return false; -}; - -function isFormatNode(node) { - return node.tagName && options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1; -} - -$.summernote.pluginEvents.insertUnorderedList = function (event, editor, layoutInfo, sorted) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable); - - var parent; - var r = range.create(); - if (!r) return; - var node = r.sc; - while (node && node !== $editable[0]) { - - parent = node.parentNode; - if (node.tagName === (sorted ? "UL" : "OL")) { - - var ul = document.createElement(sorted ? "ol" : "ul"); - ul.className = node.className; - parent.insertBefore(ul, node); - while (node.firstChild) { - ul.appendChild(node.firstChild); - } - parent.removeChild(node); - r.select(); - return; - - } else if (node.tagName === (sorted ? "OL" : "UL")) { - - var lis = []; - for (var i=0; i<node.children.length; i++) { - lis.push(node.children[i]); - } - - if (parent.tagName === "LI") { - node = parent; - parent = node.parentNode; - _.each(lis, function (li) { - parent.insertBefore(li, node); - }); - } else { - _.each(lis, function (li) { - while (li.firstChild) { - parent.insertBefore(li.firstChild, node); - } - }); - } - - parent.removeChild(node); - r.select(); - return; - - } - node = parent; - } - - var p0 = r.sc; - while (p0 && p0.parentNode && p0.parentNode !== $editable[0] && !isFormatNode(p0)) { - p0 = p0.parentNode; - } - if (!p0) return; - var p1 = r.ec; - while (p1 && p1.parentNode && p1.parentNode !== $editable[0] && !isFormatNode(p1)) { - p1 = p1.parentNode; - } - if (!p0.parentNode || p0.parentNode !== p1.parentNode) { - return; - } - - parent = p0.parentNode; - ul = document.createElement(sorted ? "ol" : "ul"); - parent.insertBefore(ul, p0); - var childNodes = parent.childNodes; - var brs = []; - var begin = false; - for (i = 0; i < childNodes.length; i++) { - if (begin && dom.isBR(childNodes[i])) { - parent.removeChild(childNodes[i]); - i--; - } - if ((!dom.isText(childNodes[i]) && !isFormatNode(childNodes[i])) || (!ul.firstChild && childNodes[i] !== p0) || - $.contains(ul, childNodes[i]) || (dom.isText(childNodes[i]) && !childNodes[i].textContent.match(/\S|u00A0/))) { - continue; - } - begin = true; - var li = document.createElement('li'); - ul.appendChild(li); - li.appendChild(childNodes[i]); - if (li.firstChild === p1) { - break; - } - i--; - } - if (dom.isBR(childNodes[i])) { - parent.removeChild(childNodes[i]); - } - - for (i = 0; i < brs.length ; i++) { - parent.removeChild(brs[i]); - } - r.clean().select(); - event.preventDefault(); - return false; -}; -$.summernote.pluginEvents.insertOrderedList = function (event, editor, layoutInfo) { - return $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, true); -}; -$.summernote.pluginEvents.indent = function (event, editor, layoutInfo, outdent) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable); - var r = range.create(); - if (!r) return; - - var flag = false; - function indentUL(UL, start, end) { - var next; - var tagName = UL.tagName; - var node = UL.firstChild; - var ul = document.createElement(tagName); - var li = document.createElement("li"); - li.style.listStyle = "none"; - li.appendChild(ul); - - if (flag) { - flag = 1; - } - - // create and fill ul into a li - while (node) { - if (flag === 1 || node === start || $.contains(node, start)) { - flag = true; - node.parentNode.insertBefore(li, node); - } - next = dom.nextElementSibling(node); - if (flag) { - ul.appendChild(node); - } - if (node === end || $.contains(node, end)) { - flag = false; - break; - } - node = next; - } - - var temp; - var prev = dom.previousElementSibling(li); - if (prev && prev.tagName === "LI" && (temp = dom.firstElementChild(prev)) && temp.tagName === tagName && ((dom.firstElementChild(prev) || prev.firstChild) !== ul)) { - dom.doMerge(dom.firstElementChild(prev) || prev.firstChild, ul); - li = prev; - li.parentNode.removeChild(dom.nextElementSibling(li)); - } - next = dom.nextElementSibling(li); - if (next && next.tagName === "LI" && (temp = dom.firstElementChild(next)) && temp.tagName === tagName && (dom.firstElementChild(li) !== dom.firstElementChild(next))) { - dom.doMerge(dom.firstElementChild(li), dom.firstElementChild(next)); - li.parentNode.removeChild(dom.nextElementSibling(li)); - } - } - function outdenttUL(UL, start, end) { - var next; - var node = UL.firstChild; - var parent = UL.parentNode; - var li = UL.parentNode.tagName === "LI" ? UL.parentNode : UL; - var ul = UL.parentNode.tagName === "LI" ? UL.parentNode.parentNode : UL.parentNode; - start = dom.ancestor(start, dom.isLi); - end = dom.ancestor(end, dom.isLi); - - if (ul.tagName !== "UL" && ul.tagName !== "OL") return; - - // create and fill ul into a li - while (node) { - if (node === start || $.contains(node, start)) { - flag = true; - if (dom.previousElementSibling(node) && li.tagName === "LI") { - li = dom.splitTree(li, dom.prevPoint({'node': node, 'offset': 0})); - } - } - next = dom.nextElementSibling(node); - if (flag) { - ul = node.parentNode; - li.parentNode.insertBefore(node, li); - if (!ul.children.length) { - if (ul.parentNode.tagName === "LI") { - ul = ul.parentNode; - } - ul.parentNode.removeChild(ul); - } - } - - if (node === end || $.contains(node, end)) { - flag = false; - break; - } - node = next; - } - - dom.merge(parent, start, 0, end, 1, null, true); - } - function indentOther(p, start, end) { - if (p === start || $.contains(p, start) || $.contains(start, p)) { - flag = true; - } - if (flag) { - if (outdent) { - dom.outdent(p); - } else { - dom.indent(p); - } - } - if (p === end || $.contains(p, end) || $.contains(end, p)) { - flag = false; - } - } - - var ancestor = r.commonAncestor(); - var $dom = $(ancestor); - - if (!dom.isList(ancestor)) { - // to indent a selection, we indent the child nodes of the common - // ancestor that contains this selection - $dom = $(dom.node(ancestor)).children(); - } - if (!$dom.not('br').length) { - // if selection is inside a list, we indent its list items - $dom = $(dom.ancestor(r.sc, dom.isList)); - if (!$dom.length) { - // if the selection is contained in a single HTML node, we indent - // the first ancestor 'content block' (P, H1, PRE, ...) or TD - $dom = $(r.sc).closest(options.styleTags.join(',')+',td'); - } - } - - // if select tr, take the first td - $dom = $dom.map(function () { return this.tagName === "TR" ? dom.firstElementChild(this) : this; }); - - $dom.each(function () { - if (flag || $.contains(this, r.sc)) { - if (dom.isList(this)) { - if (outdent) { - outdenttUL(this, r.sc, r.ec); - } else { - indentUL(this, r.sc, r.ec); - } - } else if (isFormatNode(this) || dom.ancestor(this, dom.isCell)) { - indentOther(this, r.sc, r.ec); - } - } - }); - - if ($dom.length) { - var $parent = $dom.parent(); - - // remove text nodes between lists - var $ul = $parent.find('ul, ol'); - if (!$ul.length) { - $ul = $(dom.ancestor(r.sc, dom.isList)); - } - $ul.each(function () { - if (this.previousSibling && - this.previousSibling !== dom.previousElementSibling(this) && - !this.previousSibling.textContent.match(/\S/)) { - this.parentNode.removeChild(this.previousSibling); - } - if (this.nextSibling && - this.nextSibling !== dom.nextElementSibling(this) && - !this.nextSibling.textContent.match(/\S/)) { - this.parentNode.removeChild(this.nextSibling); - } - }); - - // merge same ul or ol - r = dom.merge($parent[0], r.sc, r.so, r.ec, r.eo, function (prev, cur) { - if (prev && dom.isList(prev) && dom.isEqual(prev, cur)) { - return true; - } - }, true); - range.create(r.sc, r.so, r.ec, r.eo).select(); - } - event.preventDefault(); - return false; -}; -$.summernote.pluginEvents.outdent = function (event, editor, layoutInfo) { - return $.summernote.pluginEvents.indent(event, editor, layoutInfo, true); -}; - -$.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTagName) { - $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, "Default"); - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable); - event.preventDefault(); - - var r = range.create(); - if (!r) { - return; - } - // select content since container (that firefox selects) may be removed - if (r.so === 0) { - r.sc = dom.firstChild(r.sc); - } - if (dom.nodeLength(r.ec) >= r.eo) { - r.ec = dom.lastChild(r.ec); - r.eo = dom.nodeLength(r.ec); - } - r = range.create(r.sc, r.so, r.ec, r.eo); - r.reRange().select(); - - if (sTagName === "blockquote" || sTagName === "pre") { - sTagName = $.summernote.core.agent.isMSIE ? '<' + sTagName + '>' : sTagName; - document.execCommand('FormatBlock', false, sTagName); - return; - } - - // fix by odoo because if you select a style in a li with no p tag all the ul is wrapped by the style tag - var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo); - for (var i=0; i<nodes.length; i++) { - if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && dom.isVisibleText(nodes[i])) || dom.isB(nodes[i]) || dom.isU(nodes[i]) || dom.isS(nodes[i]) || dom.isI(nodes[i]) || dom.isFont(nodes[i])) { - var ancestor = dom.ancestor(nodes[i], isFormatNode); - if ($(ancestor).parent().is('blockquote')) { - // firefox may wrap formatting block in blockquote - $(ancestor).unwrap(); - } - if (!ancestor) { - dom.wrap(nodes[i], sTagName); - } else if (ancestor.tagName.toLowerCase() !== sTagName) { - var tag = document.createElement(sTagName); - ancestor.parentNode.insertBefore(tag, ancestor); - dom.moveContent(ancestor, tag); - if (ancestor.className) { - tag.className = ancestor.className; - } - ancestor.parentNode.removeChild(ancestor); - } - } - } - r.select(); -}; -$.summernote.pluginEvents.removeFormat = function (event, editor, layoutInfo, value) { - var $editable = layoutInfo.editable(); - $editable.data('NoteHistory').recordUndo($editable); - var r = range.create(); - if (!r) return; - var node = range.create().sc.parentNode; - document.execCommand('removeFormat'); - document.execCommand('removeFormat'); - r = range.create(); - if (!r) return; - r = dom.merge(node, r.sc, r.so, r.ec, r.eo, null, true); - range.create(r.sc, r.so, r.ec, r.eo).select(); - event.preventDefault(); - return false; -}; - -eventHandler.modules.editor.undo = function ($popover) { - if (!$popover.attr('disabled')) $popover.data('NoteHistory').undo(); -}; -eventHandler.modules.editor.redo = function ($popover) { - if (!$popover.attr('disabled')) $popover.data('NoteHistory').redo(); -}; - -// Get color and background color of node to update recent color button -var fn_from_node = eventHandler.modules.editor.style.fromNode; -eventHandler.modules.editor.style.fromNode = function ($node) { - var styleInfo = fn_from_node.apply(this, arguments); - styleInfo['color'] = $node.css('color'); - styleInfo['background-color'] = $node.css('background-color'); - return styleInfo; -}; - -// use image toolbar if current range is on image -var fn_editor_currentstyle = eventHandler.modules.editor.currentStyle; -eventHandler.modules.editor.currentStyle = function (target) { - var styleInfo = fn_editor_currentstyle.apply(this, arguments); - // with our changes for inline editor, the targeted element could be a button of the editor - if (!styleInfo.image || !dom.isEditable(styleInfo.image)) { - styleInfo.image = undefined; - var r = range.create(); - if (r) - styleInfo.image = r.isOnImg(); - } - // Fix when the target is a link: the text-align buttons state should - // indicate the alignment of the link in the parent, not the text inside - // the link (which is not possible to customize with summernote). Summernote fixed - // this in their newest version... by just not showing the active button - // for alignments. - if (styleInfo.anchor) { - styleInfo['text-align'] = $(styleInfo.anchor).parent().css('text-align'); - } - return styleInfo; -}; - -options.fontSizes = [_t('Default'), 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 62]; -$.summernote.pluginEvents.applyFont = function (event, editor, layoutInfo, color, bgcolor, size) { - var r = range.create(); - if (!r) return; - var startPoint = r.getStartPoint(); - var endPoint = r.getEndPoint(); - - if (r.isCollapsed() && !dom.isFont(r.sc)) { - return { - sc: startPoint.node, - so: startPoint.offset, - ec: endPoint.node, - offset: endPoint.offset - }; - } - - if (startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset]) { - startPoint.node = startPoint.node.childNodes[startPoint.offset]; - startPoint.offset = 0; - } - if (endPoint.node.tagName && endPoint.node.childNodes[endPoint.offset]) { - endPoint.node = endPoint.node.childNodes[endPoint.offset]; - endPoint.offset = 0; - } - - // get first and last point - var ancestor; - var node; - if (endPoint.offset && endPoint.offset !== dom.nodeLength(endPoint.node)) { - ancestor = dom.ancestor(endPoint.node, dom.isFont) || endPoint.node; - dom.splitTree(ancestor, endPoint); - } - if (startPoint.offset && startPoint.offset !== dom.nodeLength(startPoint.node)) { - ancestor = dom.ancestor(startPoint.node, dom.isFont) || startPoint.node; - node = dom.splitTree(ancestor, startPoint); - if (endPoint.node === startPoint.node) { - endPoint.node = node; - endPoint.offset = dom.nodeLength(node); - } - startPoint.node = node; - startPoint.offset = 0; - } - - // get list of nodes to change - var nodes = []; - dom.walkPoint(startPoint, endPoint, function (point) { - var node = point.node; - if (((dom.isText(node) && dom.isVisibleText(node)) || - (dom.isFont(node) && !dom.isVisibleText(node))) && - (node !== endPoint.node || endPoint.offset)) { - - nodes.push(point.node); - - } - }); - nodes = list.unique(nodes); - - // If ico fa - if (r.isCollapsed()) { - nodes.push(startPoint.node); - } - - // apply font: foreColor, backColor, size (the color can be use a class text-... or bg-...) - var font, $font, fonts = [], className; - var i; - if (color || bgcolor || size) { - for (i=0; i<nodes.length; i++) { - node = nodes[i]; - - font = dom.ancestor(node, dom.isFont); - if (!font) { - if (node.textContent.match(/^[ ]|[ ]$/)) { - node.textContent = node.textContent.replace(/^[ ]|[ ]$/g, '\u00A0'); - } - - font = dom.create("font"); - node.parentNode.insertBefore(font, node); - font.appendChild(node); - } - - fonts.push(font); - - className = font.className.split(/\s+/); - - var k; - if (color) { - for (k=0; k<className.length; k++) { - if (className[k].length && className[k].slice(0,5) === "text-") { - className.splice(k,1); - k--; - } - } - - if (color.indexOf('text-') !== -1) { - font.className = className.join(" ") + " " + color; - font.style.color = "inherit"; - } else { - font.className = className.join(" "); - font.style.color = color; - } - } - if (bgcolor) { - for (k=0; k<className.length; k++) { - if (className[k].length && className[k].slice(0,3) === "bg-") { - className.splice(k,1); - k--; - } - } - - if (bgcolor.indexOf('bg-') !== -1) { - font.className = className.join(" ") + " " + bgcolor; - font.style.backgroundColor = "inherit"; - } else { - font.className = className.join(" "); - font.style.backgroundColor = bgcolor; - } - } - if (size) { - font.style.fontSize = "inherit"; - if (!isNaN(size) && Math.abs(parseInt(dom.getComputedStyle(font).fontSize, 10)-size)/size > 0.05) { - font.style.fontSize = size + "px"; - } - } - } - } - - // remove empty values - // we must remove the value in 2 steps (applay inherit then remove) because some - // browser like chrome have some time an error for the rendering and/or keep inherit - for (i=0; i<fonts.length; i++) { - font = fonts[i]; - if (font.style.backgroundColor === "inherit") { - font.style.backgroundColor = ""; - } - if (font.style.color === "inherit") { - font.style.color = ""; - } - if (font.style.fontSize === "inherit") { - font.style.fontSize = ""; - } - - $font = $(font); - - if (!$font.css("color") && !$font.css("background-color") && !$font.css("font-size")) { - $font.removeAttr("style"); - } - if (!font.className.length) { - $font.removeAttr("class"); - } - } - - // select nodes to clean (to remove empty font and merge same nodes) - nodes = []; - dom.walkPoint(startPoint, endPoint, function (point) { - nodes.push(point.node); - }); - nodes = list.unique(nodes); - - function remove(node, to) { - if (node === endPoint.node) { - endPoint = dom.prevPoint(endPoint); - } - if (to) { - dom.moveContent(node, to); - } - dom.remove(node); - } - - // remove node without attributes (move content), and merge the same nodes - var className2, style, style2; - for (i=0; i<nodes.length; i++) { - node = nodes[i]; - - if ((dom.isText(node) || dom.isBR(node)) && !dom.isVisibleText(node)) { - remove(node); - nodes.splice(i,1); - i--; - continue; - } - - font = dom.ancestor(node, dom.isFont); - node = font || dom.ancestor(node, dom.isSpan); - - if (!node) { - continue; - } - - $font = $(node); - className = dom.orderClass(node); - style = dom.orderStyle(node); - - if (!className && !style) { - remove(node, node.parentNode); - nodes.splice(i,1); - i--; - continue; - } - - if (i>0 && (font = dom.ancestor(nodes[i-1], dom.isFont))) { - className2 = font.getAttribute('class'); - style2 = font.getAttribute('style'); - if (node !== font && className === className2 && style === style2) { - remove(node, font); - nodes.splice(i,1); - i--; - continue; - } - } - } - - range.create(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset).select(); -}; -$.summernote.pluginEvents.fontSize = function (event, editor, layoutInfo, value) { - var $editable = layoutInfo.editable(); - event.preventDefault(); - $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, value); - editor.afterCommand($editable); -}; -$.summernote.pluginEvents.color = function (event, editor, layoutInfo, sObjColor) { - var oColor = JSON.parse(sObjColor); - var foreColor = oColor.foreColor, backColor = oColor.backColor; - - if (foreColor) { $.summernote.pluginEvents.foreColor(event, editor, layoutInfo, foreColor); } - if (backColor) { $.summernote.pluginEvents.backColor(event, editor, layoutInfo, backColor); } -}; -$.summernote.pluginEvents.foreColor = function (event, editor, layoutInfo, foreColor) { - var $editable = layoutInfo.editable(); - $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, foreColor, null, null); - editor.afterCommand($editable); -}; -$.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backColor) { - var $editable = layoutInfo.editable(); - var r = range.create(); - if (!r) return; - if (r.isCollapsed() && r.isOnCell()) { - var cell = dom.ancestor(r.sc, dom.isCell); - cell.className = cell.className.replace(new RegExp('(^|\\s+)bg-[^\\s]+(\\s+|$)', 'gi'), ''); - cell.style.backgroundColor = ""; - if (backColor.indexOf('bg-') !== -1) { - cell.className += ' ' + backColor; - } else if (backColor !== 'inherit') { - cell.style.backgroundColor = backColor; - } - return; - } - $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, backColor, null); - editor.afterCommand($editable); -}; - -options.onCreateLink = function (sLinkUrl) { - if (sLinkUrl.indexOf('mailto:') === 0 || sLinkUrl.indexOf('tel:') === 0) { - // pass - } else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) { - sLinkUrl = 'mailto:' + sLinkUrl; - } else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl[0] !== '/' - && sLinkUrl[0] !== '#' && sLinkUrl.slice(0, 2) !== '${') { - sLinkUrl = 'http://' + sLinkUrl; - } - return sLinkUrl; -}; - -function summernote_table_scroll(event) { - var r = range.create(); - if (r && r.isOnCell()) { - $('.o_table_handler').remove(); - } -} -function summernote_table_update(oStyle) { - var r = range.create(); - if (!oStyle.range || !r || !r.isOnCell() || !r.isOnCellFirst()) { - $('.o_table_handler').remove(); - return; - } - var table = dom.ancestor(oStyle.range.sc, dom.isTable); - if (!table) { // if the editable tag is inside the table - return; - } - var $editable = $(table).closest('.o_editable'); - - $('.o_table_handler').remove(); - - var $dels = $(); - var $adds = $(); - var $tds = $('tr:first', table).children(); - $tds.each(function () { - var $td = $(this); - var pos = $td.offset(); - - var $del = $('<span class="o_table_handler fa fa-minus-square"/>').appendTo('body'); - $del.data('td', this); - $dels = $dels.add($del); - $del.css({ - left: ((pos.left + $td.outerWidth()/2)-6) + "px", - top: (pos.top-6) + "px" - }); - - var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body'); - $add.data('td', this); - $adds = $adds.add($add); - $add.css({ - left: (pos.left-6) + "px", - top: (pos.top-6) + "px" - }); - }); - - var $last = $tds.last(); - var pos = $last.offset(); - var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body'); - $adds = $adds.add($add); - $add.css({ - left: (pos.left+$last.outerWidth()-6) + "px", - top: (pos.top-6) + "px" - }); - - var $table = $(table); - $dels.data('table', table).on('mousedown', function (event) { - var td = $(this).data('td'); - $editable.data('NoteHistory').recordUndo($editable); - - var newTd; - if ($(td).siblings().length) { - var eq = $(td).index(); - $table.find('tr').each(function () { - $('> td:eq('+eq+')', this).remove(); - }); - newTd = $table.find('tr:first > td:eq('+eq+'), tr:first > td:last').first(); - } else { - var prev = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling($table[0]))); - $table.remove(); - $('.o_table_handler').remove(); - r = range.create(prev, prev.textContent.length); - r.select(); - $(r.sc).trigger('mouseup'); - return; - } - - $('.o_table_handler').remove(); - range.create(newTd[0], 0, newTd[0], 0).select(); - newTd.trigger('mouseup'); - }); - $adds.data('table', table).on('mousedown', function (event) { - var td = $(this).data('td'); - $editable.data('NoteHistory').recordUndo($editable); - - var newTd; - if (td) { - var eq = $(td).index(); - $table.find('tr').each(function () { - $('td:eq('+eq+')', this).before('<td>'+dom.blank+'</td>'); - }); - newTd = $table.find('tr:first td:eq('+eq+')'); - } else { - $table.find('tr').each(function () { - $(this).append('<td>'+dom.blank+'</td>'); - }); - newTd = $table.find('tr:first td:last'); - } - - $('.o_table_handler').remove(); - range.create(newTd[0], 0, newTd[0], 0).select(); - newTd.trigger('mouseup'); - }); - - $dels.css({ - 'position': 'absolute', - 'cursor': 'pointer', - 'background-color': '#fff', - 'color': '#ff0000' - }); - $adds.css({ - 'position': 'absolute', - 'cursor': 'pointer', - 'background-color': '#fff', - 'color': '#00ff00' - }); -} -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) { - summernote_table_update(oStyle); - } -}; - -var fn_attach = eventHandler.attach; -eventHandler.attach = function (oLayoutInfo, options) { - var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable'); - fn_attach.call(this, oLayoutInfo, options); - $editable.on("scroll", summernote_table_scroll); -}; -var fn_detach = eventHandler.detach; -eventHandler.detach = function (oLayoutInfo, options) { - var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable'); - fn_detach.call(this, oLayoutInfo, options); - $editable.off("scroll", summernote_table_scroll); - $('.o_table_handler').remove(); -}; - -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/translator.js b/addons/web_editor/static/src/js/editor/translator.js deleted file mode 100644 index c6cadb40562..00000000000 --- a/addons/web_editor/static/src/js/editor/translator.js +++ /dev/null @@ -1,375 +0,0 @@ -odoo.define('web_editor.translate', function (require) { -'use strict'; - -var core = require('web.core'); -var Dialog = require('web.Dialog'); -var localStorage = require('web.local_storage'); -var Widget = require('web.Widget'); -var weContext = require('web_editor.context'); -var rte = require('web_editor.rte'); -var weWidgets = require('web_editor.widget'); -var Dialog = require('web.Dialog'); - -var _t = core._t; - -var localStorageNoDialogKey = 'website_translator_nodialog'; - -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) { - var self = this; - if ($el.data('oe-translation-id')) { - return this._rpc({ - model: 'ir.translation', - method: 'save_html', - args: [ - [+$el.data('oe-translation-id')], - this._getEscapedElement($el).html() - ], - context: context, - }).fail(function (error) { - Dialog.alert(null, error.data.message); - }); - } - return this._super($el, context, withLang === undefined ? true : withLang); - }, -}); - -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', click: this.save} - ], - }, options || {})); - this.$target = $(node); - this.translation = $(node).data('translation'); - }, - /** - * @override - */ - start: function () { - var self = this; - var $group = $('<div/>', {class: 'form-group'}).appendTo(this.$el); - _.each(this.translation, function (node, attr) { - var $node = $(node); - var $label = $('<label class="col-form-label"></label>').text(attr); - var $input = $('<input class="form-control"/>').val($node.html()); - $input.on('change keyup', function () { - var value = $input.val(); - $node.html(value).trigger('change', node); - $node.data('$node').attr($node.data('attribute'), value).trigger('translate'); - self.trigger_up('rte_change', {target: node}); - }); - $group.append($label).append($input); - }); - return this._super.apply(this, arguments); - } -}); - -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._super.apply(this, arguments); - - 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]'); - var attrs = ['placeholder', 'title', 'alt']; - _.each(attrs, function (attr) { - $target.find('['+attr+'*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img').each(function () { - var $node = $(this); - var translation = $node.data('translation') || {}; - var trans = $node.attr(attr); - var match = trans.match(/<span [^>]*data-oe-translation-id="([0-9]+)"[^>]*>(.*)<\/span>/); - var $trans = $(trans).addClass('d-none o_editable o_editable_translatable_attribute').appendTo('body'); - $trans.data('$node', $node).data('attribute', attr); - translation[attr] = $trans[0]; - $node.attr(attr, match[2]); - - var select2 = $node.data('select2'); - if (select2) { - select2.blur(); - $node.on('translate', function () { - select2.blur(); - }); - $node = select2.container.find('input'); - } - $node.addClass('o_translatable_attribute').data('translation', translation); - }); - }); - 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); - }, - /** - * @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); - return _t('This document is not saved!'); - } - }; - this.$target.addClass('o_editable'); - this.rte.start(); - this.translations = []; - 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); - }, - /** - * @override - */ - destroy: function () { - this._cancel(); - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // 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); - }); - this._unmarkTranslatableNode(); - this.trigger('cancel'); - this.$el.hide(); - window.onbeforeunload = null; - }, - /** - * 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) { - id = $node.data('oe-model')+','+$node.data('oe-id')+','+$node.data('oe-field'); - } - var trans = _.find(this.translations, function (trans) { - return trans.id === id; - }); - if (!trans) { - this.translations.push(trans = {'id': id}); - } - return trans; - }, - /** - * @private - */ - _markTranslatableNodes: function () { - var self = this; - this.$target.add(this.$target_attribute).each(function () { - var $node = $(this); - var trans = self._getTranlationObject(this); - trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' '); - }); - this.$target.parent().prependEvent('click.translator', function (ev) { - if (ev.ctrlKey || !$(ev.target).is(':o_editable')) { - return; - } - ev.preventDefault(); - ev.stopPropagation(); - }); - - // attributes - - this.$target_attr.each(function () { - var $node = $(this); - var translation = $node.data('translation'); - _.each(translation, function (node, attr) { - 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', function (ev) { - if (ev.ctrlKey) { - return; - } - ev.preventDefault(); - ev.stopPropagation(); - if (ev.type !== 'mousedown') { - return; - } - - new AttributeTranslateDialog(self, {}, ev.target).open(); - }); - }, - /** - * 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'); - }, - - //-------------------------------------------------------------------------- - // 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 <p/> 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(); - }, -}); - -return { - Class: TranslatorMenuBar, -}; -}); diff --git a/addons/web_editor/static/src/js/iframe.js b/addons/web_editor/static/src/js/iframe.js deleted file mode 100644 index f2c5e3be661..00000000000 --- a/addons/web_editor/static/src/js/iframe.js +++ /dev/null @@ -1,153 +0,0 @@ -odoo.define('web_editor.iframe', function (require) { -'use strict'; - -var core = require('web.core'); -var editor = require('web_editor.editor'); -var translator = require('web_editor.translate'); -var rte = require('web_editor.rte'); - -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); - - if ($('body').hasClass('editor_enable') && value !== fields_values[field_name]) { - $editable.trigger('content_changed'); - } -}; - -editor.Class.include({ - start: function () { - this.on('rte:start', this, function () { - this.$('form').hide(); - - if (window.top.odoo[callback + '_editor']) { - window.top.odoo[callback + '_editor'](this); - } - - var $editable = $('#editable_area'); - setTimeout(function () { - $($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')); - } - }); - }); - - return this._super.apply(this, arguments).then(function () { - $(window.top).trigger('resize'); // TODO check, probably useless - }); - } -}); - -rte.Class.include({ - /** - * @override - */ - _getDefaultConfig: function ($editable) { - var config = this._super.apply(this, arguments); - if ($.deparam($.param.querystring()).debug !== undefined) { - 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); - } - return res; - }, -}); -}); - -//============================================================================== - -odoo.define('root.widget', 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 deleted file mode 100644 index fb2247313bd..00000000000 --- a/addons/web_editor/static/src/js/inline.js +++ /dev/null @@ -1,91 +0,0 @@ -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 weWidgets = require('web_editor.widget'); -var transcoder = require('web_editor.transcoder'); -var snippet_editor = require('web_editor.snippet.editor'); - -weWidgets.MediaDialog.include({ - start: function () { - this.$('[href="#editor-media-video"]').addClass('d-none'); - return this._super.apply(this, arguments); - }, -}); - -editor.Class.include({ - start: function () { - 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')) { - _.defer(function () { - var node = $('#editable_area *') - .filter(function () { return this.textContent.match(/\S|\u00A0/); }) - .add($('#editable_area')) - .last()[0]; - $.summernote.core.range.create(node, $.summernote.core.dom.nodeLength(node)).select(); - }); - } - }); - }); - } - return this._super.apply(this, arguments); - }, -}); - -snippet_editor.Class.include({ - start: function () { - _.defer(function () { - var $editable = $('#editable_area'); - transcoder.linkImgToAttachmentThumbnail($editable); - 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); - }, - cleanForSave: function () { - this._super.apply(this, arguments); - - var $editable = $('#editable_area'); - transcoder.attachmentThumbnailToLinkImg($editable); - transcoder.fontToImg($editable); - transcoder.classToStyle($editable); - - // fix outlook image rendering bug - _.each(['width', 'height'], function (attribute) { - $editable.find('img[style*="width"], img[style*="height"]').attr(attribute, function () { - return $(this)[attribute](); - }).css(attribute, function () { - return $(this).get(0).style[attribute] || 'auto'; - }); - }); - }, -}); - -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); - core.bus.trigger('deactivate_snippet'); - - $editable.html(value); - - 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'); - } -}; -}); diff --git a/addons/web_editor/static/src/js/tours/rte.js b/addons/web_editor/static/src/js/tours/rte.js deleted file mode 100644 index abdcd39f35b..00000000000 --- a/addons/web_editor/static/src/js/tours/rte.js +++ /dev/null @@ -1,325 +0,0 @@ -odoo.define('web_editor.tour', function (require) { -'use strict'; - -var core = require('web.core'); -var tour = require('web_tour.tour'); -var base = require('web_editor.base'); - -/** - * 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'+ - '<section>\n'+ - ' <div class="container" style="width: 600px;">\n'+ - ' <div class="row">\n'+ - ' <div class="col-lg-6 mt16">\n'+ - '<h1 id="text_content_id">Batnae municipium in Anthemusia</h1> \n'+ - ' <p>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.</p>\n'+ - ' <p> Quam <img style="width: 25%" src="/web/static/src/img/logo.png"/> quidem <span class="fa fa-flag fa-2x"></span> partem accusationis admiratus sum et moleste tuli potissimum esse Atratino datam. Neque enim decebat neque aetas.</p>\n'+ - ' <p>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.</p>'+ - '<p>Harum trium sententiarum nulli prorsus assentior.</p>\n'+ - ' </div>\n'+ - ' <div class="col-lg-6 mt16">\n'+ - ' <img class="img img-fluid shadow mb16" src="/web/static/src/img/logo.png" alt="Odoo text and image block">\n'+ - ' </div>\n'+ - ' </div>\n'+ - '</section>\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-color button.dropdown-toggle:has(.fa-paint-brush)', - extra_trigger: '#editable_area > section .row > div:first', -}, { - content: "change text backColor", - trigger: "button[data-event=backColor]:visible:eq(2)", -}, { - 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:has(.fa-font)", -}, { - content: "change text color", - trigger: "button[data-event=foreColor]:visible: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:has(.fa-paint-brush)", -}, { - 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:has(.fa-font)", -}, { - 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: '.modal-footer > .btn-primary', - 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.float-right', -}, { - content: "insert a link url", - trigger: 'input[name="url"]', - run: "text http://www.odoo.com", -}, { - 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>$/) > span.fa.fa-3x.float-right', -}, { - content: "save link", - trigger: '.modal-footer > .btn-primary', - extra_trigger: 'a#link-preview.btn.btn-success span.fa.fa-3x.float-right', -}, { - 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.float-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.float-right', -}, { - content: "insert an email", - trigger: 'input[name="url"]', - run: "text test@test.test", -}, { - 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 [^>]+>$/) img', -}, { - content: "save link", - trigger: '.modal-footer > .btn-primary', - extra_trigger: 'a#link-preview.btn.btn-success[href="mailto:test@test.test"]:containsRegex(/^<img [^>]+>$/) img', -}, { - 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 = ' <ul> '+ - '\n <li> <p>Batnae municipium. </p></li>'+ - '\n <li> Seleucia praeter.</li>'+ - '\n <li><p>Et hanc quidem.</p></li>'+ - '\n </ul>'; - 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'+ - '<div>\n'+ - ' <table cellspacing="0" cellpadding="0" width="100%">\n'+ - ' <tbody>\n'+ - ' <tr>\n'+ - ' <td valign="center" width="270">\n'+ - ' <img src="/logo.png" alt="Your Logo" class="rounded-circle img-thumbnail">\n'+ - ' </td>\n'+ - ' <td valign="center" width="270">\n'+ - ' <a href="https://www.facebook.com/Odoo"><span class="fa fa-facebook-square fa-2x text-primary"></span></a>\n'+ - ' <span style="color: rgb(255, 0, 0);" class="fa fa-4x fa-google-plus-square float-right"></span>\n'+ - ' </td>\n'+ - ' </tr>\n'+ - ' </tbody>\n'+ - ' </table>\n'+ - '</div>'; - 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(0,160,157)/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"],#wrapwrap img:eq(2)[style*="float:right"]', -}]); -}); diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/wysiwyg/fonts.js index d69cace3100..257ccaf26a3 100644 --- a/addons/web_editor/static/src/js/base.js +++ b/addons/web_editor/static/src/js/wysiwyg/fonts.js @@ -1,12 +1,6 @@ -odoo.define('web_editor.base', function (require) { +odoo.define('wysiwyg.fonts', function (require) { 'use strict'; -var ajax = require('web.ajax'); -var session = require('web.session'); - -var domReady = $.Deferred(); -$(domReady.resolve.bind(domReady)); - return { /** * Retrieves all the CSS rules which match the given parser (Regex). @@ -101,57 +95,5 @@ return { data.alias = _.flatten(_.map(data.cssData, _.property('names'))); }); }), - /** - * 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') || '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, }; }); - -//============================================================================== - -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/wysiwyg/options.js b/addons/web_editor/static/src/js/wysiwyg/options.js new file mode 100644 index 00000000000..aeaf8422b72 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/options.js @@ -0,0 +1,6 @@ +odoo.define('web_editor.wysiwyg.options', function (require) { +'use strict'; + +return $.summernote.options; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js b/addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js new file mode 100644 index 00000000000..de776020a42 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js @@ -0,0 +1,222 @@ +odoo.define('web_editor.wysiwyg.plugin.abstract', function (require) { +'use strict'; + +var Class = require('web.Class'); +var mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); +var wysiwygTranslation = require('web_editor.wysiwyg.translation'); +var wysiwygOptions = require('web_editor.wysiwyg.options'); + +//-------------------------------------------------------------------------- +// AbstractPlugin for summernote module API +//-------------------------------------------------------------------------- + +var AbstractPlugin = Class.extend(mixins.EventDispatcherMixin, ServicesMixin).extend({ + /** + * Use this prop if you want to extend a summernote plugin. + */ + init: function (context) { + var self = this; + this._super.apply(this, arguments); + this.setParent(context.options.parent); + this.context = context; + + if (!this.context.invoke) { + // for use outside of wysiwyg/summernote + this.context.invoke = function () {}; + } + + this.$editable = context.layoutInfo.editable; + this.editable = this.$editable[0]; + this.document = this.editable.ownerDocument; + this.window = this.document.defaultView; + this.summernote = this.window._summernoteSlave || $.summernote; // if the target is in iframe + this.ui = this.summernote.ui; + this.$editingArea = context.layoutInfo.editingArea; + this.options = _.defaults(context.options || {}, wysiwygOptions); + this.lang = wysiwygTranslation; + this._addButtons(); + if (this.events) { + this.events = _.clone(this.events); + _.each(_.keys(this.events), function (key) { + var value = self.events[key]; + if (typeof value === 'string') { + value = self[value].bind(self); + } + if (key.indexOf('summernote.') === 0) { + self.events[key] = value; + } else { + delete self.events[key]; + key = key.split(' '); + if (key.length > 1) { + self.context.layoutInfo.editor.on(key[0], key.slice(1).join(' '), value); + } else { + self.context.layoutInfo.editor.on(key, value); + } + } + }); + } + }, + + //-------------------------------------------------------------------------- + // Public summernote module API + //-------------------------------------------------------------------------- + + shouldInitialize: function () { + return true; + }, + initialize: function () {}, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Override to add buttons. + */ + _addButtons: function () {}, + /** + * Creates a dropdown button with its contents and behavior. + * + * @param {str} optionName + * @param {str} buttonIcon (ex.: 'note-icon-align') + * @param {str} buttonTooltip (ex.: 'Align Paragraph') + * @param {Object[]} values (ex.: [{value: 'padding-small', string: 'S'}]) + * @param {function} onclick + */ + _createDropdownButton: function (optionName, buttonIcon, buttonTooltip, values, onclick) { + var self = this; + + if (!onclick) { + onclick = function (e) { + var classNames = _.map(values, function (item) { + return item.value; + }).join(' '); + var $target = $(self.context.invoke('editor.restoreTarget')); + $target.removeClass(classNames); + if ($(e.target).data('value')) { + $target.addClass($(e.target).data('value')); + } + }; + } + if (optionName) { + this.context.memo('button.' + optionName, function () { + return self._renderDropdownButton(buttonIcon, buttonTooltip, values, onclick); + }); + } else { + return this._renderDropdownButton(buttonIcon, buttonTooltip, values, onclick); + } + }, + /** + * Creates a button to toggle a class. + * + * @param {str} optionName + * @param {str} buttonIcon (ex.: 'note-icon-align') + * @param {str} buttonTooltip (ex.: 'Align Paragraph') + * @param {str} className + */ + _createToggleButton: function (optionName, buttonIcon, buttonTooltip, className) { + var self = this; + return this._createButton(optionName, buttonIcon, buttonTooltip, function () { + var $target = $(self.context.invoke('editor.restoreTarget')); + $target.toggleClass(className); + }); + }, + /** + * Creates a button. + * + * @param {str} optionName + * @param {str} buttonIcon (ex.: 'note-icon-align') + * @param {str} buttonTooltip (ex.: 'Align Paragraph') + * @param {function} onclick + */ + _createButton: function (optionName, buttonIcon, buttonTooltip, onclick) { + var self = this; + if (optionName) { + this.context.memo('button.' + optionName, function () { + return self._renderButton(buttonIcon, buttonTooltip, onclick); + }); + } else { + return this._renderButton(buttonIcon, buttonTooltip, onclick); + } + }, + /** + * Helper function to _createButton: renders the button. + * + * @param {str} buttonIcon (ex.: 'note-icon-align') + * @param {str} buttonTooltip (ex.: 'Align Paragraph') + * @param {function} onclick + * @returns {JQuery} + */ + _renderButton: function (buttonIcon, buttonTooltip, onclick) { + var self = this; + return this.context.invoke('buttons.button', { + contents: buttonIcon.indexOf('<') === -1 ? this.ui.icon(buttonIcon) : buttonIcon, + tooltip: buttonTooltip, + click: function (e) { + e.preventDefault(); + self.context.invoke('editor.beforeCommand'); + onclick(e); + self.editable.normalize(); + self.context.invoke('editor.saveRange'); + self.context.invoke('editor.afterCommand'); + }, + }).render(); + }, + /** + * Helper function to _createDropdownButton: renders the dropdown button. + * + * @param {str} buttonIcon (ex.: 'note-icon-align') + * @param {str} buttonTooltip (ex.: 'Align Paragraph') + * @param {Object[]} values (ex.: [{value: 'padding-small', string: 'S'}]) + * @param {function} onclick + * @param {JQuery} + */ + _renderDropdownButton: function (buttonIcon, buttonTooltip, values, onclick) { + return this.ui.buttonGroup([ + this.context.invoke('buttons.button', { + className: 'dropdown-toggle', + contents: buttonIcon.indexOf('<') === -1 ? + this.ui.dropdownButtonContents(this.ui.icon(buttonIcon), this.options) : buttonIcon, + tooltip: buttonTooltip, + data: { + toggle: 'dropdown' + } + }), + this.ui.dropdown({ + items: values, + template: function (item) { + return item.string; + }, + click: this._wrapCommand(function (e) { + e.preventDefault(); + onclick(e); + }), + }) + ]).render(); + }, + /** + * Wraps a given function between common actions required + * for history (undo/redo) and the maintenance of the DOM/range. + * + * @param {function} fn + * @returns {any} the return of fn + */ + _wrapCommand: function (fn) { + var self = this; + return function () { + self.context.invoke('editor.restoreRange'); + self.context.invoke('editor.beforeCommand'); + var res = fn.apply(self, arguments); + self.editable.normalize(); + self.context.invoke('editor.saveRange'); + self.context.invoke('editor.afterCommand'); + return res; + }; + }, + +}); + +return AbstractPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js b/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js new file mode 100644 index 00000000000..506646d4697 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js @@ -0,0 +1,565 @@ +odoo.define('web_editor.wysiwyg.plugin.bullet', function (require) { +'use strict'; + +var core = require('web.core'); +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var wysiwygOptions = require('web_editor.wysiwyg.options'); +var registry = require('web_editor.wysiwyg.plugin.registry'); +var wysiwygTranslation = require('web_editor.wysiwyg.translation'); + +var _t = core._t; +var dom = $.summernote.dom; + +wysiwygOptions.icons.checklist = 'fa fa-check-square'; +wysiwygOptions.keyMap.pc['CTRL+SHIFT+NUM9'] = 'insertCheckList'; +wysiwygOptions.keyMap.mac['CMD+SHIFT+NUM9'] = 'insertCheckList'; +wysiwygTranslation.lists.checklist = _t('Checklist'); +wysiwygTranslation.help.checklist = _t('Toggle checkbox list'); + + +var BulletPlugin = AbstractPlugin.extend({ + events: { + 'summernote.mousedown': '_onMouseDown', + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Insert an ordered list, an unordered list or a checklist. + * If already in list, remove the list or convert it to the given type. + * + * @param {string('ol'|'ul'|'checklist')} type the type of list to insert + * @returns {false|Node[]} contents of the ul/ol or content of the converted/removed list + */ + insertList: function (type) { + var range = this.context.invoke('editor.createRange'); + if (!range) { + return; + } + var res; + var start = range.getStartPoint(); + var end = range.getEndPoint(); + + var isInList = dom.ancestor(range.sc, dom.isList); + if (isInList) { + res = this._convertList(false, [], start, end, type); + } else { + var ul = this._createList(type); + res = [].slice.call(ul.children); + } + + var startLeaf = this.context.invoke('HelperPlugin.firstLeaf', start.node); + var endLeaf = this.context.invoke('HelperPlugin.firstLeaf', end.node); + range = this.context.invoke('editor.setRange', startLeaf, start.offset, endLeaf, end.offset); + range.select(); + this.context.invoke('editor.saveRange'); + + return res; + }, + /** + * Indent a node (list or format node). + * + * @returns {false|Node[]} contents of list/indented item + */ + indent: function () { + return this._indent(); + }, + /** + * Outdent a node (list or format node). + * + * @returns {false|Node[]} contents of list/outdented item + */ + outdent: function () { + return this._indent(true); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add checklist buttons. + */ + _addButtons: function () { + var self = this; + this._super(); + this.context.memo('help.checklist', this.lang.help.checklist); + this._createButton('checklist', this.options.icons.checklist, this.lang.lists.checklist, function (e) { + e.preventDefault(); + self.context.invoke('editor.insertCheckList'); + }); + }, + /** + * Convert ul<->ol or remove ul/ol. + * + * @param {boolean} isWithinElem true if selection already inside the LI + * @param {DOM[]} nodes selected nodes + * @param {Object} startPoint + * @param {Object} endPoint + * @param {boolean} sorted + * @returns {boolean} isWithinElem + */ + _convertList: function (isWithinElem, nodes, startPoint, endPoint, sorted) { + var ol = dom.ancestor(startPoint.node, dom.isList); + var parent = ol.parentNode; + + // get selected lis + + var lis = []; + var lisBefore = []; + var lisAfter = []; + _.each(ol.children, function (li) { + if (!isWithinElem && (li === startPoint.node || $.contains(li, startPoint.node))) { + isWithinElem = true; + } + if (isWithinElem) { + lis.push(li); + } else if (lis.length) { + lisAfter.push(li); + } else { + lisBefore.push(li); + } + if (isWithinElem && (li === endPoint.node || $.contains(li, endPoint.node))) { + isWithinElem = false; + } + }); + + var res = lis; + + if (lisBefore.length) { + var ulBefore = this.document.createElement(ol.tagName); + ulBefore.className = ol.className; + + if (dom.isLi(ol.parentNode)) { + var li = this.document.createElement('li'); + li.className = ol.parentNode.className; + $(li).insertBefore(ol.parentNode); + li.appendChild(ulBefore); + } else { + $(ulBefore).insertBefore(ol); + } + + $(ulBefore).append(lisBefore); + } + if (lisAfter.length) { + var ulAfter = this.document.createElement(ol.tagName); + ulAfter.className = ol.className; + + if (dom.isLi(ol.parentNode)) { + var li = this.document.createElement('li'); + li.className = ol.parentNode.className; + $(li).insertAfter(ol.parentNode); + li.appendChild(ulAfter); + } else { + $(ulAfter).insertAfter(ol); + } + + $(ulAfter).append(lisAfter); + } + + // convert ul<->ol or remove list + var current = ol.tagName === 'UL' && ol.className.indexOf('o_checklist') !== -1 ? 'checklist' : ol.tagName.toLowerCase(); + if (current !== sorted) { + // convert ul <-> ol + + var ul; + if (sorted === 'checklist' && current === "ul") { + ul = ol; + } else if (sorted === 'ul' && current === 'checklist') { + $(ol).removeClass('o_checklist'); + ul = ol; + } else { + ul = this.document.createElement(sorted === "ol" ? "ol" : "ul"); + ul.className = ol.className; + $(ul).insertBefore(ol).append(lis); + parent.removeChild(ol); + } + if (sorted === 'checklist') { + $(ul).addClass('o_checklist'); + } + + this.context.invoke('HelperPlugin.deleteEdge', ul, 'next'); + this.context.invoke('HelperPlugin.deleteEdge', ul, 'prev'); + + } else { + // remove ol/ul + + if (dom.isLi(parent) || dom.isList(parent)) { + if (dom.isLi(parent)) { + ol = parent; + parent = ol.parentNode; + } + $(lis).insertBefore(ol); + } else { + res = []; + _.each(lis, function (li) { + res.push.apply(res, li.childNodes); + $(li.childNodes).insertBefore(ol); + }); + + // wrap in p + + var hasNode = _.find(res, function (node) { + return node.tagName && node.tagName !== "BR" && !dom.isMedia(node); + }); + if (!hasNode) { + var p = this.document.createElement('p'); + $(p).insertBefore(ol).append(res); + res = [p]; + } + } + parent.removeChild(ol); + + } + + nodes.push.apply(nodes, res); + + return isWithinElem; + }, + /** + * Create a list if allowed. + * + * @param {string('ol'|'ul'|'checklist')} type the type of list to insert + * @returns {false|Node} the list, if any + */ + _createList: function (type) { + var nodes = this.context.invoke('HelperPlugin.getSelectedNodes'); + var formatNodes = this._filterEditableFormatNodes(nodes); + if (!formatNodes.length) { + return; + } + + var ul = this._createListElement(type); + $(formatNodes[0][0] || formatNodes[0]).before(ul); + this._fillListElementWith(ul, formatNodes); + this._deleteListElementEdges(ul); + + return ul; + }, + /** + * Create a list element of the given type and return it. + * + * @param {string('ol'|'ul'|'checklist')} type the type of list to insert + * @returns {Node} + */ + _createListElement: function (type) { + var ul = this.document.createElement(type === "ol" ? "ol" : "ul"); + if (type === 'checklist') { + ul.className = 'o_checklist'; + } + return ul; + }, + /** + * Delete a list element's edges if necessary. + * + * @param {Node} ul + */ + _deleteListElementEdges: function (ul) { + this.context.invoke('HelperPlugin.deleteEdge', ul, 'next'); + this.context.invoke('HelperPlugin.deleteEdge', ul, 'prev'); + this.editable.normalize(); + }, + /** + * Fill a list element with the nodes passed, wrapped in LIs. + * + * @param {Node} ul + * @param {Node[]} nodes + */ + _fillListElementWith: function (ul, nodes) { + var self = this; + _.each(nodes, function (node) { + var li = self.document.createElement('li'); + $(li).append(node); + ul.appendChild(li); + }); + }, + /** + * Filter the editable format ancestors of the given nodes + * and fill or wrap them if needed for range selection. + * + * @param {Node[]} nodes + * @returns {Node[]} + */ + _filterEditableFormatNodes: function (nodes) { + var self = this; + var formatNodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes); + formatNodes = _.compact(_.map(formatNodes, function (node) { + var ancestor = (!node.tagName || node.tagName === 'BR') && dom.ancestor(node, dom.isCell); + if (ancestor && self.options.isEditableNode(ancestor)) { + if (!ancestor.childNodes.length) { + var br = self.document.createElement('br'); + ancestor.appendChild(br); + } + var p = self.document.createElement('p'); + $(p).append(ancestor.childNodes); + ancestor.appendChild(p); + return p; + } + return self.options.isEditableNode(node) && node || null; + })); + return formatNodes; + }, + /** + * Indent or outdent a format node. + * + * @param {bool} outdent true to outdent, false to indent + * @returns {false|[]Node} indented nodes + */ + _indent: function (outdent) { + var range = this.context.invoke('editor.createRange'); + if (!range) { + return; + } + + var self = this; + var nodes = []; + var isWithinElem; + var ancestor = range.commonAncestor(); + var $dom = $(ancestor); + + if (!dom.isList(ancestor)) { + // to indent a selection, we indent the child nodes of the common + // ancestor that contains this selection + $dom = $(ancestor.tagName ? ancestor : ancestor.parentNode).children(); + } + + // if selection is inside indented contents and outdent is true, we can outdent this node + var indentedContent = outdent && dom.ancestor(ancestor, function (node) { + var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft'; + return node.tagName && !!parseFloat(node.style[style] || 0); + }); + + if (indentedContent) { + $dom = $(indentedContent); + } else { + // if selection is inside a list, we indent its list items + $dom = $(dom.ancestor(ancestor, dom.isList)); + if (!$dom.length) { + // if the selection is contained in a single HTML node, we indent + // the first ancestor 'content block' (P, H1, PRE, ...) or TD + $dom = $(range.sc).closest(this.options.styleTags.join(',') + ',td'); + } + } + + // if select tr, take the first td + $dom = $dom.map(function () { + return this.tagName === "TR" ? this.firstElementChild : this; + }); + + $dom.each(function () { + if (isWithinElem || $.contains(this, range.sc)) { + if (dom.isList(this)) { + if (outdent) { + var type = this.tagName === 'OL' ? 'ol' : (this.className && this.className.indexOf('o_checklist') !== -1 ? 'checklist' : 'ul'); + isWithinElem = self._convertList(isWithinElem, nodes, range.getStartPoint(), range.getEndPoint(), type); + } else { + isWithinElem = self._indentUL(isWithinElem, nodes, this, range.sc, range.ec); + } + } else if (self.context.invoke('HelperPlugin.isFormatNode', this) || dom.ancestor(this, dom.isCell)) { + isWithinElem = self._indentFormatNode(outdent, isWithinElem, nodes, this, range.sc, range.ec); + } + } + }); + + if ($dom.parent().length) { + var $parent = $dom.parent(); + + // remove text nodes between lists + var $ul = $parent.find('ul, ol'); + if (!$ul.length) { + $ul = $(dom.ancestor(range.sc, dom.isList)); + } + $ul.each(function () { + var notWhitespace = /\S/; + if ( + this.previousSibling && + this.previousSibling !== this.previousElementSibling && + !this.previousSibling.textContent.match(notWhitespace) + ) { + this.parentNode.removeChild(this.previousSibling); + } + if ( + this.nextSibling && + this.nextSibling !== this.nextElementSibling && + !this.nextSibling.textContent.match(notWhitespace) + ) { + this.parentNode.removeChild(this.nextSibling); + } + }); + + // merge same ul or ol + $ul.prev('ul, ol').each(function () { + self.context.invoke('HelperPlugin.deleteEdge', this, 'next'); + }); + + } + + range.normalize().select(); + this.context.invoke('editor.saveRange'); + + return !!nodes.length && nodes; + }, + /** + * Indent several LIs in a list. + * + * @param {bool} isWithinElem true if selection already inside the LI + * @param {Node[]} nodes + * @param {Node} UL + * @param {Node} start + * @param {Node} end + * @returns {bool} isWithinElem + */ + _indentUL: function (isWithinElem, nodes, UL, start, end) { + var next; + var tagName = UL.tagName; + var node = UL.firstChild; + var ul = document.createElement(tagName); + ul.className = UL.className; + var flag; + + if (isWithinElem) { + flag = true; + } + + // create and fill ul into a li + while (node) { + if (flag || node === start || $.contains(node, start)) { + isWithinElem = true; + node.parentNode.insertBefore(ul, node); + } + next = node.nextElementSibling; + if (isWithinElem) { + ul.appendChild(node); + nodes.push(node); + } + if (node === end || $.contains(node, end)) { + isWithinElem = false; + break; + } + node = next; + } + + var temp; + var prev = ul.previousElementSibling; + if ( + prev && prev.tagName === "LI" && + (temp = prev.firstElementChild) && temp.tagName === tagName && + ((prev.firstElementChild || prev.firstChild) !== ul) + ) { + $(prev.firstElementChild || prev.firstChild).append($(ul).contents()); + $(ul).remove(); + ul = prev; + ul.parentNode.removeChild(ul.nextElementSibling); + } + next = ul.nextElementSibling; + if ( + next && next.tagName === "LI" && + (temp = next.firstElementChild) && temp.tagName === tagName && + (ul.firstElementChild !== next.firstElementChild) + ) { + $(ul.firstElementChild).append($(next.firstElementChild).contents()); + $(next.firstElementChild).remove(); + ul.parentNode.removeChild(ul.nextElementSibling); + } + + // wrap in li + var li = this.document.createElement('li'); + li.className = 'o_indent'; + $(ul).before(li); + li.appendChild(ul); + + return isWithinElem; + }, + /** + * Outdent a container node. + * + * @param {Node} node + * @returns {float} margin + */ + _outdentContainer: function (node) { + var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft'; + var margin = parseFloat(node.style[style] || 0) - 1.5; + node.style[style] = margin > 0 ? margin + "em" : ""; + return margin; + }, + /** + * Indent a container node. + * + * @param {Node} node + * @returns {float} margin + */ + _indentContainer: function (node) { + var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft'; + var margin = parseFloat(node.style[style] || 0) + 1.5; + node.style[style] = margin + "em"; + return margin; + }, + /** + * Indent/outdent a format node. + * + * @param {bool} outdent true to outdent, false to indent + * @param {bool} isWithinElem true if selection already inside the element + * @param {DOM[]} nodes + * @param {DOM} p + * @param {DOM} start + * @param {DOM} end + * @returns {bool} isWithinElem + */ + _indentFormatNode: function (outdent, isWithinElem, nodes, p, start, end) { + if (p === start || $.contains(p, start) || $.contains(start, p)) { + isWithinElem = true; + } + if (isWithinElem) { + nodes.push(p); + if (outdent) { + this._outdentContainer(p); + } else { + this._indentContainer(p); + } + } + if (p === end || $.contains(p, end) || $.contains(end, p)) { + isWithinElem = false; + } + return isWithinElem; + }, + + //-------------------------------------------------------------------------- + // Handle + //-------------------------------------------------------------------------- + + /** + * @param {SummernoteEvent} se + * @param {jQueryEvent} e + */ + _onMouseDown: function (se, e) { + if (!dom.isLi(e.target) || !$(e.target).parent('ul.o_checklist').length || e.offsetX > 0) { + return; + } + e.preventDefault(); + var checked = $(e.target).hasClass('o_checked'); + $(e.target).toggleClass('o_checked', !checked); + var $sublevel = $(e.target).next('ul.o_checklist, li:has(> ul.o_checklist)').find('> li, ul.o_checklist > li'); + var $parents = $(e.target).parents('ul.o_checklist').map(function () { + return this.parentNode.tagName === 'LI' ? this.parentNode : this; + }); + if (checked) { + $sublevel.removeClass('o_checked'); + $parents.prev('ul.o_checklist li').removeClass('o_checked'); + } else { + $sublevel.addClass('o_checked'); + var $lis; + do { + $lis = $parents.not(':has(li:not(.o_checked))').prev('ul.o_checklist li:not(.o_checked)'); + $lis.addClass('o_checked'); + } while ($lis.length); + } + }, +}); + +registry.add('BulletPlugin', BulletPlugin); + +return BulletPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js b/addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js new file mode 100644 index 00000000000..7059c448d81 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js @@ -0,0 +1,88 @@ +odoo.define('web_editor.wysiwyg.plugin.buttons', function (require) { +'use strict'; + +var Plugins = require('web_editor.wysiwyg.plugins'); + +var dom = $.summernote.dom; + +Plugins.buttons.include({ + /** + * Fix tooltip for 'option' buttons (summernote 0.8.9 bug) + * Remove this once the library is updated to 0.8.10. + * + * @override + */ + addToolbarButtons: function () { + this.options.codeview = this.lang.options.codeview; + this.options.help = this.lang.options.help; + this.options.fullscreen = this.lang.options.fullscreen; + this._super(); + }, + /** + * Show current style (of start of selection) in magic wand dropdown. + * + * @param {JQuery Object} $container + */ + updateActiveStyleDropdown: function ($container) { + var self = this; + var range = this.context.invoke('editor.createRange'); + var el = dom.ancestor(range.sc, function (n) { + return n.tagName && self.options.styleTags.indexOf(n.tagName.toLowerCase()) !== -1; + }); + if (el) { + var tagName = el.tagName.toLowerCase(); + $container.find('.dropdown-style a').each(function (idx, item) { + var $item = $(item); + // always compare string to avoid creating another func. + var isChecked = ($item.data('value') + '') === (tagName + ''); + $item.toggleClass('active', isChecked); + }); + } else { + var $item = $container.find('.dropdown-style a.active'); + $item.removeClass('active'); + } + }, + /** + * @override + */ + updateCurrentStyle: function ($container) { + this._super.apply(this, arguments); + + this.updateParaAlignIcon($container || this.$toolbar); + this.updateActiveStyleDropdown($container || this.$toolbar); + }, + /** + * Show current paragraph alignment (of start of selection) on paragraph alignment dropdown. + * + * @param {JQuery Object} $container + */ + updateParaAlignIcon: function ($container) { + var range = this.context.invoke('editor.createRange'); + var $paraIcon = $container.find('.note-para .dropdown-toggle i'); + var el = dom.isText(range.sc) ? range.sc.parentNode : range.sc; + if (el) { + $paraIcon.removeClass(); + switch ($(el).css('text-align')) { + case 'left': + $paraIcon.addClass('note-icon-align-left'); + break; + case 'center': + $paraIcon.addClass('note-icon-align-center'); + break; + case 'right': + $paraIcon.addClass('note-icon-align-right'); + break; + case 'justify': + $paraIcon.addClass('note-icon-align-justify'); + break; + default: + $paraIcon.addClass('note-icon-align-left'); + break; + } + } + }, +}); + +return Plugins.buttons; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js b/addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js new file mode 100644 index 00000000000..01ffc52b205 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js @@ -0,0 +1,60 @@ +odoo.define('web_editor.wysiwyg.plugin.codeview', function (require) { +'use strict'; + +var core = require('web.core'); +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var _t = core._t; + + +var CodeviewPlugin = Plugins.codeview.extend({ + /** + * @override + */ + activate: function () { + this._super(); + if (this.$codable.height() === 0) { + this.$codable.height(180); + } + this.context.invoke('editor.hidePopover'); + this.context.invoke('editor.clearTarget'); + }, + /** + * @override + */ + deactivate: function () { + if ( + this.context.invoke('HelperPlugin.hasJinja', this.context.invoke('code')) && + !this.isBeingDestroyed + ) { + var message = _t("Your code contains JINJA conditions.\nRemove them to return to WYSIWYG HTML edition."); + this.do_warn(_t("Cannot edit HTML"), message); + this.$codable.focus(); + return; + } + this._super(); + this.$editable.css('height', ''); + }, + /** + * @override + */ + destroy: function () { + this.isBeingDestroyed = true; + this._super(); + }, + /** + * Force activation of the code view. + */ + forceActivate: function () { + if (!this.isActivated()) { + this.activate(); + } + }, +}); + +registry.add('codeview', CodeviewPlugin); + +return CodeviewPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js new file mode 100644 index 00000000000..8727a6aa8a1 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js @@ -0,0 +1,210 @@ +odoo.define('web_editor.wysiwyg.plugin.dropzone', function (require) { +'use strict'; + +var core = require('web.core'); +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var _t = core._t; +var dom = $.summernote.dom; + +var DropzonePlugin = Plugins.dropzone.extend({ + //-------------------------------------------------------------------------- + // Public summernote module API + //-------------------------------------------------------------------------- + + /** + * Disable Summernote's handling of drop events. + */ + attachDragAndDropEvent: function () { + this._super.apply(this, arguments); + this.$dropzone.off('drop'); + this.$dropzone.on('drop', this._onDrop.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Clean up then drops HTML or plain text into the editor. + * + * @private + * @param {String} html + * @param {Boolean} textOnly true to allow only dropping plain text + */ + _dropHTML: function (html, textOnly) { + this.context.invoke('editor.beforeCommand'); + + // Clean up + var nodes = this.context.invoke('TextPlugin.prepareClipboardData', html); + + // Delete selection + this.context.invoke('HelperPlugin.deleteSelection'); + + // Insert the nodes + this.context.invoke('TextPlugin.pasteNodes', nodes, textOnly); + this.context.invoke('HelperPlugin.normalize'); + this.context.invoke('editor.saveRange'); + + this.context.invoke('editor.afterCommand'); + }, + /** + * Drop images into the editor: save them as attachments. + * + * @private + * @param {File[]]} files (images only) + */ + _dropImages: function (files) { + var self = this; + this.context.invoke('editor.beforeCommand'); + var range = this.context.invoke('editor.createRange'); + + var spinners = []; + var images = []; + var defs = []; + _.each(files, function (file) { + // Add spinner + var spinner = $('<span class="fa fa-spinner fa-spin">').attr('data-filename', file.name)[0]; + self.context.invoke('editor.hidePopover'); + if (range.sc.tagName) { + if (range.so >= dom.nodeLength(range.sc)) { + $(range.sc).append(spinner); + } else { + $(range.sc).before(range.sc.childNodes[range.so]); + } + } else { + range.sc.splitText(range.so); + $(range.sc).after(spinner); + } + spinners.push(spinner); + + // save images as attachments + var def = $.Deferred(); + defs.push(def); + // Get image's Base64 string + var reader = new FileReader(); + reader.addEventListener('load', function (e) { + self._uploadImage(e.target.result, file.name).then(function (attachment) { + // Make the HTML + var image = self.document.createElement('img'); + image.setAttribute('style', 'width: 100%;'); + image.src = '/web/content/' + attachment.id + '/' + attachment.name; + image.alt = attachment.name; + $(spinner).replaceWith(image); + images.push(image); + def.resolve(image); + $(image).trigger('dropped'); + }); + }); + reader.readAsDataURL(file); + }); + + this.trigger_up('drop_images', { + spinners: spinners, + promises: defs, + }); + + $.when.apply($, defs).then(function () { + var defs = []; + $(images).each(function () { + if (!this.height) { + var def = $.Deferred(); + defs.push(def); + $(this).one('load error abort', def.resolve.bind(def)); + } + }); + $.when.apply($, defs).then(function () { + if (images.length === 1) { + range = self.context.invoke('editor.setRange', _.last(images), 0); + range.select(); + self.context.invoke('editor.saveRange'); + self.context.invoke('editor.afterCommand'); + self.context.invoke('MediaPlugin.updatePopoverAfterEdit', images[0]); + } else { + self.context.invoke('editor.afterCommand'); + } + }); + }); + }, + /** + * Upload an image from its Base64 representation. + * + * @private + * @param {String} imageBase64 + * @param {String} fileName + * @returns {Promise} + */ + _uploadImage: function (imageBase64, fileName) { + var options = {}; + this.trigger_up('getRecordInfo', { + recordInfo: options, + type: 'media', + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + + return this._rpc({ + route: '/web_editor/add_image_base64', + params: { + res_model: options.res_model, + res_id: options.res_id, + image_base64: imageBase64.split(';base64,').pop(), + filename: fileName, + }, + }); + }, + /** + * @private + * @param {JQueryEvent} e + */ + _onDrop: function (e) { + e.preventDefault(); + + if (this.options.disableDragAndDrop) { + return; + } + var dataTransfer = e.originalEvent.dataTransfer; + + if (!this._canDropHere()) { + this.context.invoke('HelperPlugin.notify', _t("Not a dropzone"), _t("Dropping is prohibited in this area.")); + return; + } + + if (dataTransfer.getData('text/html')) { + this._dropHTML(dataTransfer.getData('text/html')); + return; + } + if (dataTransfer.files.length) { + var images = []; + _.each(dataTransfer.files, function (file) { + if (file.type.indexOf('image') !== -1) { + images.push(file); + } + }); + if (!images.length || images.length < dataTransfer.files.length) { + this.context.invoke('HelperPlugin.notify', _t("Unsupported file type"), _t("Images are the only file types that can be dropped.")); + } + if (images.length) { + this._dropImages(images); + } + } + }, + /** + * Return true if dropping is allowed at the current range. + * + * @private + * @returns {Boolean} + */ + _canDropHere: function () { + var range = this.context.invoke('editor.createRange'); + return this.options.isEditableNode(range.sc); + }, +}); + +registry.add('dropzone', DropzonePlugin); + +return DropzonePlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/editor.js b/addons/web_editor/static/src/js/wysiwyg/plugin/editor.js new file mode 100644 index 00000000000..350a9158188 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/editor.js @@ -0,0 +1,189 @@ +odoo.define('web_editor.wysiwyg.plugin.editor', function (require) { +'use strict'; + +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); +var TablePlugin = require('web_editor.wysiwyg.plugin.table'); + + +var NewSummernoteEditor = Plugins.editor.extend({ + //-------------------------------------------------------------------------- + // Public summernote module API + //-------------------------------------------------------------------------- + + initialize: function () { + var self = this; + this.history = this.context.modules.HistoryPlugin; + this.dropzone = this.context.modules.DropzonePlugin; + + this.insertTable = this.wrapCommand(this._insertTable.bind(this)); + this.insertOrderedList = this.wrapCommand(this._insertOrderedList.bind(this)); + this.insertUnorderedList = this.wrapCommand(this._insertUnorderedList.bind(this)); + this.insertCheckList = this.wrapCommand(this._insertCheckList.bind(this)); + this.indent = this.wrapCommand(this._indent.bind(this)); + this.outdent = this.wrapCommand(this._outdent.bind(this)); + this.table = new TablePlugin(this.context); + this.hasFocus = this._hasFocus.bind(this); + + this._makeTextCommand('formatBlock'); + this._makeTextCommand('removeFormat'); + _.each('bold,italic,underline,strikethrough,superscript,subscript'.split(','), function (sCmd) { + self._makeTextCommand('formatText', sCmd); + }); + _.each('justifyLeft,justifyCenter,justifyRight,justifyFull'.split(','), function (sCmd) { + self._makeTextCommand('formatBlockStyle', sCmd); + }); + + this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Hide all popovers. + */ + hidePopover: function () { + this.context.invoke('MediaPlugin.hidePopovers'); + this.context.invoke('LinkPopover.hide'); + }, + /* + * Focus the editor. + */ + focus: function () { + // [workaround] Screen will move when page is scolled in IE. + // - do focus when not focused + if (!this.hasFocus()) { + var range = $.summernote.range.create(); + if (range) { + $(range.sc).closest('[contenteditable]').focus(); + range.select(); + } else { + this.$editable.focus(); + } + } + }, + /** + * Fix double-undo (CTRL-Z) issue with Odoo integration. + * + * @override + */ + undo: function () { + this.createRange(); + this._super(); + }, + /** + * Set the range at the given nodes and offsets. + * If no `ec` is specified, the range is collapsed on start. + * Note: Does NOT select the range. + * + * @param {Node} sc + * @param {Number} so + * @param {Node} [ec] + * @param {Number} [eo] + * @returns {Object} range + */ + setRange: function (sc, so, ec, eo) { + var range = this.createRange(); + range.sc = sc; + range.so = so; + range.ec = ec || sc; + range.eo = eo || so; + return range; + }, + /** + * Remove a link and preserve its text contents. + */ + unlink: function () { + this.beforeCommand(); + this.context.invoke('LinkPlugin.unlink'); + this.afterCommand(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns true if editable area has focus. + * + * @private + * @returns {Boolean} + */ + _hasFocus: function () { + return this.$editable.is(':focus') || !!this.$editable.find('[contenteditable]:focus').length; + }, + /** + * Indent a list or a format node. + * + * @private + * @returns {false|Node[]} contents of list/indented item + */ + _indent: function () { + return this.context.invoke('BulletPlugin.indent'); + }, + /** + * Insert a checklist. + * + * @private + */ + _insertCheckList: function () { + this.context.invoke('BulletPlugin.insertList', 'checklist'); + }, + /** + * Insert an ordered list. + * + * @private + */ + _insertOrderedList: function () { + this.context.invoke('BulletPlugin.insertList', 'ol'); + }, + /** + * Insert table (respecting unbreakable node rules). + * + * @private + * @param {string} dim (eg: 3x3) + */ + _insertTable: function (dim) { + this.context.invoke('TablePlugin.insertTable', dim); + }, + /** + * Insert an ordered list. + * + * @private + */ + _insertUnorderedList: function () { + this.context.invoke('BulletPlugin.insertList', 'ul'); + }, + /** + * Adds a TextPlugin command to the editor object. + * The command will exist in the editor object under the name + * arg if specified, or commandName otherwise. + * + * @param {String} commandName + * @param {String} arg + */ + _makeTextCommand: function (commandName, arg) { + var self = this; + this[arg || commandName] = self.wrapCommand(function (value) { + self.context.invoke('TextPlugin.' + commandName, arg || value, value); + }); + }, + /** + * Outdent a list or a format node. + * + * @private + * @returns {false|Node[]} contents of list/outdented item + */ + _outdent: function () { + return this.context.invoke('BulletPlugin.outdent'); + }, +}); + +// Override Summernote default editor +registry.add('editor', NewSummernoteEditor); + +return NewSummernoteEditor; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/font.js b/addons/web_editor/static/src/js/wysiwyg/plugin/font.js new file mode 100644 index 00000000000..91ac93470ba --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/font.js @@ -0,0 +1,559 @@ +odoo.define('web_editor.wysiwyg.plugin.font', function (require) { +'use strict'; + +var core = require('web.core'); +var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog'); +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var registry = require('web_editor.wysiwyg.plugin.registry'); +var wysiwygTranslation = require('web_editor.wysiwyg.translation'); +var wysiwygOptions = require('web_editor.wysiwyg.options'); + +var QWeb = core.qweb; +var _t = core._t; + +var dom = $.summernote.dom; + +//-------------------------------------------------------------------------- +// Font (colorpicker & font-size) +//-------------------------------------------------------------------------- + +dom.isFont = function (node) { + return node && node.tagName === "FONT" || dom.isIcon(node); +}; + +var FontPlugin = AbstractPlugin.extend({ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Creates both ColorPicker buttons (fore- and backcolors) containing their palettes. + * + * @returns {jQuery} the buttons' div.btn-group container + */ + createColorPickerButtons: function () { + var self = this; + var $container = $('<div class="note-btn-group btn-group note-color"/>'); + + // create and append the buttons + + this._createForeColorButton().appendTo($container); + this._createBackColorButton().appendTo($container); + + // add event + + $container.on('mousedown', function () { + self.context.invoke('editor.saveRange'); + }); + return $container; + }, + /** + * Takes the font size button created by summernote and override its + * events so as to customize its behavior. + * + * @param {jQuery} $button the button, as created by summernote + * @returns {jQuery} the overridden button + */ + overrideFontSizeButton: function ($button) { + var self = this; + $button.click(function (e) { + e.preventDefault(); + }); + $button.find('.dropdown-menu').off('click').on('mousedown', function (e) { + e.preventDefault(); + self.context.invoke('editor.createRange'); + self.context.invoke('editor.beforeCommand'); + var $target = $(e.target); + self.context.invoke('FontPlugin.changeFontSize', $target.closest('[data-value]').data('value'), $target); + self.context.invoke('buttons.updateCurrentStyle'); + self.context.invoke('editor.saveRange'); + self.context.invoke('editor.afterCommand'); + }); + return $button; + }, + /** + * Creates a (fore- or back-) color palette. + * + * @param {string('foreColor'|'backColor')} eventName + * @returns {jQuery} + */ + createPalette: function (eventName) { + var self = this; + var colors = _.clone(this.options.colors); + colors.splice(0, 1); // Ignore the summernote gray palette and use ours + var $palette = $(QWeb.render('wysiwyg.plugin.font.colorPalette', { + colors: colors, + eventName: eventName, + lang: this.lang, + })); + if (this.options.tooltip) { + $palette.find('.note-color-btn').tooltip({ + container: this.options.container, + trigger: 'hover', + placement: 'bottom' + }); + } + + // custom structure for the color picker and custom colorsin XML template + var $clpicker = $(QWeb.render('web_editor.colorpicker')); + + var $buttons = $(_.map($clpicker.children(), function (group) { + var $contents = $(group).contents(); + if (!$contents.length) { + return ''; + } + var $row = $("<div/>", { + "class": "note-color-row mb8 clearfix", + 'data-group': $(group).data('name'), + }).append($contents); + var $after_breaks = $row.find(".o_small + :not(.o_small)"); + if ($after_breaks.length === 0) { + $after_breaks = $row.find(":nth-child(8n+9)"); + } + $after_breaks.addClass("o_clear"); + return $row[0].outerHTML; + }).join("")); + + $buttons.find('button').each(function () { + var color = $(this).data('color'); + $(this).addClass('note-color-btn bg-' + color).attr('data-value', (eventName === 'backColor' ? 'bg-' : 'text-') + color); + }); + + $palette.find('.o_theme_color_placeholder').prepend($buttons.filter('[data-group="theme"]')); + $palette.find('.o_transparent_color_placeholder').prepend($buttons.filter('[data-group="transparent_grayscale"]')); + $palette.find('.o_common_color_placeholder').prepend($buttons.filter('[data-group="common"]')); + + $palette.off('click').on('mousedown', '.note-color-btn', function (e) { + e.preventDefault(); + self.context.invoke('editor.createRange'); + self.context.invoke('editor.beforeCommand'); + var method = eventName === 'backColor' ? 'changeBgColor' : 'changeForeColor'; + var $target = $(e.target); + self.context.invoke('FontPlugin.' + method, $target.closest('[data-value]').data('value'), $target); + self.context.invoke('buttons.updateCurrentStyle'); + self.editable.normalize(); + self.context.invoke('editor.saveRange'); + self.context.invoke('editor.afterCommand'); + }); + $palette.on('mousedown', '.note-custom-color', this._onCustomColor.bind(this, eventName)); + + return $palette; + }, + /** + * Change the selection's fore color. + * + * @param {string} color (hexadecimal or class name) + */ + changeForeColor: function (color) { + this._applyFont(color || 'text-undefined', null, null); + }, + /** + * Change the selection's background color. + * + * @param {string} color (hexadecimal or class name) + */ + changeBgColor: function (color) { + this._applyFont(null, color || 'bg-undefined', null); + }, + /** + * Change the selection's font size. + * + * @param {integer} fontsize + */ + changeFontSize: function (fontsize) { + this._applyFont(null, null, fontsize || 'inherit'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Applies the given styles (fore- or backcolor, font size) + * to a given <font> node. + * + * @private + * @param {Node} node + * @param {string} color (hexadecimal or class name) + * @param {string} bgcolor (hexadecimal or class name) + * @param {integer} size + * @returns {Node} the <font> node + */ + _applyStylesToFontNode: function (node, color, bgcolor, size) { + var className = node.className.split(this.context.invoke('HelperPlugin.getRegex', 'space')); + var k; + if (color) { + for (k = 0; k < className.length; k++) { + if (className[k].length && className[k].slice(0, 5) === "text-") { + className.splice(k, 1); + k--; + } + } + if (color === 'text-undefined') { + node.className = className.join(" "); + node.style.color = "inherit"; + } else if (color.indexOf('text-') !== -1) { + node.className = className.join(" ") + " " + color; + node.style.color = "inherit"; + } else { + node.className = className.join(" "); + node.style.color = color; + } + } + if (bgcolor) { + for (k = 0; k < className.length; k++) { + if (className[k].length && className[k].slice(0, 3) === "bg-") { + className.splice(k, 1); + k--; + } + } + + if (bgcolor === 'bg-undefined') { + node.className = className.join(" "); + node.style.backgroundColor = "inherit"; + } else if (bgcolor.indexOf('bg-') !== -1) { + node.className = className.join(" ") + " " + bgcolor; + node.style.backgroundColor = "inherit"; + } else { + node.className = className.join(" "); + node.style.backgroundColor = bgcolor; + } + } + if (size) { + node.style.fontSize = "inherit"; + if (!isNaN(size) && Math.abs(parseInt(this.window.getComputedStyle(node).fontSize, 10) - size) / size > 0.05) { + node.style.fontSize = size + "px"; + } + } + return node; + }, + /** + * Applies the given styles (fore- or backcolor, font size) to the selection. + * If no text is selected, apply to the current text node, if any. + * + * @private + * @param {string} color (hexadecimal or class name) + * @param {string} bgcolor (hexadecimal or class name) + * @param {integer} fontsize + */ + _applyFont: function (color, bgcolor, size) { + var self = this; + var r = this.context.invoke('editor.createRange'); + if (!r || !this.$editable.has(r.sc).length || !this.$editable.has(r.ec).length) { + return; + } + var target; + var font; + if (r.isCollapsed()) { + if (dom.isIcon(r.sc)) { + target = dom.lastAncestor(r.sc, dom.isIcon); + } else { + target = this.context.invoke('editor.restoreTarget'); + if (target && dom.isIcon(target)) { + r = this.context.invoke('editor.setRange', target, 0); + } else if (dom.isText(r.sc)) { + font = dom.create("font"); + font.appendChild(this.document.createTextNode('\u200B')); + + var fontParent = dom.ancestor(r.sc, function (n) { + return n.tagName === 'FONT'; + }); + var right; + if (fontParent) { + right = this.context.invoke('HelperPlugin.splitTree', fontParent, r.getStartPoint()); + } else { + right = r.sc.splitText(r.so); + } + $(right).before(font); + font = this._applyStylesToFontNode(font, color, bgcolor, size); + r = this.context.invoke('editor.setRange', font, 1); + r.select(); + return; + } + } + } + + var startPoint = r.getStartPoint(); + var endPoint = r.getEndPoint(); + if (startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset]) { + startPoint.node = startPoint.node.childNodes[startPoint.offset]; + startPoint.offset = 0; + } + if (endPoint.node.tagName && endPoint.node.childNodes[endPoint.offset]) { + endPoint.node = endPoint.node.childNodes[endPoint.offset]; + endPoint.offset = 0; + } + // get first and last point + var ancestor; + var node; + if (!r.isCollapsed()) { + if (endPoint.offset && endPoint.offset !== dom.nodeLength(endPoint.node)) { + ancestor = dom.lastAncestor(endPoint.node, dom.isFont) || endPoint.node; + dom.splitTree(ancestor, endPoint); + } + if (startPoint.offset && startPoint.offset !== dom.nodeLength(startPoint.node)) { + ancestor = dom.lastAncestor(startPoint.node, dom.isFont) || startPoint.node; + node = dom.splitTree(ancestor, startPoint); + if (endPoint.node === startPoint.node) { + endPoint.node = node; + endPoint.offset = dom.nodeLength(node); + } + startPoint.node = node; + startPoint.offset = 0; + } + } + // get list of nodes to change + var nodes = []; + dom.walkPoint(startPoint, endPoint, function (point) { + var node = point.node; + if (((dom.isText(node) && self.context.invoke('HelperPlugin.isVisibleText', node)) || dom.isIcon(node)) && + (node !== endPoint.node || endPoint.offset)) { + nodes.push(point.node); + } + }); + nodes = _.unique(nodes); + // if fontawesome + if (r.isCollapsed()) { + nodes.push(startPoint.node); + } + + // apply font: foreColor, backColor, size (the color can be use a class text-... or bg-...) + var $font; + var fonts = []; + var style; + var className; + var i; + if (color || bgcolor || size) { + for (i = 0; i < nodes.length; i++) { + node = nodes[i]; + font = dom.lastAncestor(node, dom.isFont); + if (!font) { + if (node.textContent.match(this.context.invoke('HelperPlugin.getRegex', 'startAndEndSpace'))) { + node.textContent = node.textContent.replace(this.context.invoke('HelperPlugin.getRegex', 'startAndEndSpace', 'g'), '\u00A0'); + } + font = dom.create("font"); + node.parentNode.insertBefore(font, node); + font.appendChild(node); + } + fonts.push(font); + this._applyStylesToFontNode(font, color, bgcolor, size); + } + } + // remove empty values + // we must remove the value in 2 steps (applay inherit then remove) because some + // browser like chrome have some time an error for the rendering and/or keep inherit + for (i = 0; i < fonts.length; i++) { + font = fonts[i]; + if (font.style.color === "inherit") { + font.style.color = ""; + } + if (font.style.backgroundColor === "inherit") { + font.style.backgroundColor = ""; + } + if (font.style.fontSize === "inherit") { + font.style.fontSize = ""; + } + $font = $(font); + if (font.style.color === '' && font.style.backgroundColor === '' && font.style.fontSize === '') { + $font.removeAttr("style"); + } + if (!font.className.length) { + $font.removeAttr("class"); + } + } + + // target the deepest node + if (startPoint.node.tagName && !startPoint.offset) { + startPoint.node = this.context.invoke('HelperPlugin.firstLeaf', startPoint.node.childNodes[startPoint.offset] || startPoint.node); + startPoint.offset = 0; + } + if (endPoint.node.tagName && !endPoint.offset) { + endPoint.node = this.context.invoke('HelperPlugin.firstLeaf', endPoint.node.childNodes[endPoint.offset] || endPoint.node); + endPoint.offset = 0; + } + + // select nodes to clean (to remove empty font and merge same nodes) + nodes = []; + dom.walkPoint(startPoint, endPoint, function (point) { + nodes.push(point.node); + }); + nodes = _.unique(nodes); + // remove node without attributes (move content), and merge the same nodes + for (i = 0; i < nodes.length; i++) { + node = nodes[i]; + if (dom.isText(node) && !this.context.invoke('HelperPlugin.isVisibleText', node)) { + continue; + } + font = dom.lastAncestor(node, dom.isFont); + node = font || dom.ancestor(node, dom.isSpan); + if (!node) { + continue; + } + $font = $(node); + className = this.context.invoke('HelperPlugin.orderClass', node); + style = this.context.invoke('HelperPlugin.orderStyle', node); + if (!className && !style) { + $(node).before($(node).contents()); + if (endPoint.node === node) { + endPoint = dom.prevPointUntil(endPoint, function (point) { + return point.node !== node; + }); + } + $(node).remove(); + + nodes.splice(i, 1); + i--; + continue; + } + var prev = font && font.previousSibling; + while (prev && !font.tagName && !this.context.invoke('HelperPlugin.isVisibleText', prev)) { + prev = prev.previousSibling; + } + if (prev && + font.tagName === prev.tagName && + className === this.context.invoke('HelperPlugin.orderClass', prev) && style === this.context.invoke('HelperPlugin.orderStyle', prev)) { + $(prev).append($(font).contents()); + if (endPoint.node === font) { + endPoint = dom.prevPointUntil(endPoint, function (point) { + return point.node !== font; + }); + } + $(font).remove(); + + nodes.splice(i, 1); + i--; + continue; + } + } + + // restore selection + r = this.context.invoke('editor.setRange', startPoint.node, startPoint.offset, endPoint.node, endPoint.offset); + r.normalize().select(); + + if (target) { + this.context.invoke('MediaPlugin.updatePopoverAfterEdit', target); + } + }, + /** + * Creates the backcolor button containing its palette. + * + * @private + * @returns {jQuery} the backcolor button + */ + _createBackColorButton: function () { + var $bgPalette = this.createPalette('backColor'); + $bgPalette.find("button:not(.note-color-btn)") + .addClass("note-color-btn") + .attr('data-event', 'backColor') + .each(function () { + var $el = $(this); + var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'bg-' + $el.data('color'); + $el.attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : className); + }); + + var $bgContainer = $(QWeb.render('wysiwyg.plugin.font.paletteButton', { + className: 'note-bg-color', + icon: this.options.icons.bg, + })); + $bgContainer.find('.dropdown-menu').append($bgPalette); + return $bgContainer; + }, + /** + * Creates the forecolor button containing its palette. + * + * @private + * @returns {jQuery} the forecolor button + */ + _createForeColorButton: function () { + var $forePalette = this.createPalette('foreColor'); + $forePalette.find("button:not(.note-color-btn)") + .addClass("note-color-btn") + .attr('data-event', 'foreColor') + .each(function () { + var $el = $(this); + var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'text-' + $el.data('color'); + $el.attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : 'bg-' + $el.data('color')); + }); + + var $foreContainer = $(QWeb.render('wysiwyg.plugin.font.paletteButton', { + className: 'note-fore-color', + icon: this.options.icons.fore, + })); + $foreContainer.find('.dropdown-menu').append($forePalette); + return $foreContainer; + }, + /** + * Method called on custom color button click : + * opens the color picker dialog and saves the chosen color on save. + * + * @private + * @param {string} targetColor + * @param {jQuery Event} ev + */ + _onCustomColor: function (targetColor, ev) { + ev.preventDefault(); + ev.stopPropagation(); + + var self = this; + var $button = $(ev.target).next('button'); + var target = this.context.invoke('editor.restoreTarget'); + var colorPickerDialog = new ColorpickerDialog(this, {}); + + this.context.invoke('editor.saveRange'); + colorPickerDialog.on('colorpicker:saved', this, this._wrapCommand(function (ev) { + $button = $button.clone().appendTo($button.parent()); + $button.show(); + $button.css('background-color', ev.data.cssColor); + $button.attr('data-value', ev.data.cssColor); + $button.data('value', ev.data.cssColor); + $button.attr('title', ev.data.cssColor); + self.context.invoke('editor.saveTarget', target); + self.context.invoke('editor.restoreRange'); + $button.mousedown(); + })); + colorPickerDialog.open(); + this.context.invoke('MediaPlugin.hidePopovers'); + }, +}); + +_.extend(wysiwygOptions.icons, { + fore: 'fa fa-font', + bg: 'fa fa-paint-brush', +}); +_.extend(wysiwygTranslation.color, { + customColor: _t('Custom color'), + fore: _t('Color'), + bg: _t('Background color'), +}); + +registry.add('FontPlugin', FontPlugin); + +registry.addXmlDependency('/web_editor/static/src/xml/wysiwyg_colorpicker.xml'); +registry.addJob(function (wysiwyg) { + if ('web_editor.colorpicker' in QWeb.templates) { + return; + } + var options = {}; + wysiwyg.trigger_up('getRecordInfo', { + recordInfo: options, + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + return wysiwyg._rpc({ + model: 'ir.ui.view', + method: 'read_template', + args: ['web_editor.colorpicker'], + kwargs: { + context: options.context, + }, + }).then(function (template) { + QWeb.add_template(template); + }); +}); + + +return FontPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js b/addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js new file mode 100644 index 00000000000..f30b16b8bfc --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js @@ -0,0 +1,31 @@ +odoo.define('web_editor.wysiwyg.plugin.font.buttons', function (require) { +'use strict'; + +var ButtonsPlugin = require('web_editor.wysiwyg.plugin.buttons'); + +//-------------------------------------------------------------------------- +// override the ColorPicker button into the Toolbar and the font size button +//-------------------------------------------------------------------------- + +ButtonsPlugin.include({ + /** + * @override + */ + addToolbarButtons: function () { + var self = this; + this._super(); + + this.context.memo('button.colorpicker', function () { + return self.context.invoke('FontPlugin.createColorPickerButtons'); + }); + + var fontsizeFunction = this.context.memo('button.fontsize'); + this.context.memo('button.fontsize', function () { + return self.context.invoke('FontPlugin.overrideFontSizeButton', fontsizeFunction()); + }); + }, +}); + +return ButtonsPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js b/addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js new file mode 100644 index 00000000000..2a28cbb221d --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js @@ -0,0 +1,27 @@ +odoo.define('web_editor.wysiwyg.plugin.HelpDialog', function (require) { +'use strict'; + +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +/** + * Allows to customize link content and style. + */ +var HelpDialog = Plugins.helpDialog.extend({ + /** + * Restore the hidden close button. + */ + showHelpDialog: function () { + var self = this; + return this._super().then(function () { + self.$dialog.find('button.close span').attr('aria-hidden', 'false'); + }); + }, +}); + +registry.add('helpDialog', HelpDialog); + +return { + HelpDialog: HelpDialog, +}; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js b/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js new file mode 100644 index 00000000000..098186c75a8 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js @@ -0,0 +1,1843 @@ +odoo.define('web_editor.wysiwyg.plugin.helper', function (require) { +'use strict'; + +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var Dialog = require('web.Dialog'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var dom = $.summernote.dom; + + +var HelperPlugin = AbstractPlugin.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This dictionary contains oft-used regular expressions, + * for performance and readability purposes. It can be + * accessed and extended by the getRegex() method. + */ + regex: { + char: { + noflag: /\S|\u00A0|\u200B/, + }, + emptyElemWithBR: { + noflag: /^\s*<br\/?>\s*$/, + }, + endInvisible: { + noflag: /\u200B$/, + }, + endNotChar: { + noflag: /[^\S\u00A0\u200B]+$/, + }, + endSingleSpace: { + noflag: /[\S\u00A0\u200B]\s$/, + }, + endSpace: { + noflag: /\s+$/, + }, + invisible: { + noflag: /\u200B/, + }, + jinja: { + noflag: /(^|\n)\s*%\send|%\sset/, + }, + notWhitespace: { + noflag: /\S/, + }, + onlyEmptySpace: { + noflag: /^[\s\u00A0\u200B]*(<br>)?[\s\u00A0\u200B]*$/, + }, + semicolon: { + noflag: / ?; ?/, + }, + space: { + noflag: /\s+/, + g: /\s+/g, + }, + spaceOrNewline: { + noflag: /[\s\n\r]+/, + g: /[\s\n\r]+/g, + }, + startAndEndInvisible: { + noflag: /^\u200B|\u200B$/, + g: /^\u200B|\u200B$/g, + }, + startAndEndSpace: { + noflag: /^\s+|\s+$/, + g: /^\s+|\s+$/g, + }, + startAndEndSemicolon: { + noflag: /^ ?;? ?| ?;? ?$/, + }, + startInvisible: { + noflag: /^\u200B/, + }, + startNotChar: { + noflag: /^[^\S\u00A0\u200B]+/, + }, + startSingleSpace: { + noflag: /^\s[\S\u00A0\u200B]/, + }, + startSpace: { + noflag: /^\s+/, + }, + }, + + /** + * Compares two nodes to see if they are similar. + * "Similar" means that they have the same tag, styles, classes and attributes. + * + * @param {Node} node + * @param {Node} otherNode + * @returns {Boolean} true if the nodes are similar + */ + compareNodes: function (node, otherNode) { + if (!otherNode || !node) { + return false; + } + if (node.tagName !== otherNode.tagName) { + return false; + } + if (dom.isText(node)) { + return true; + } + this.removeBlankAttrs(node); + this.removeBlankAttrs(otherNode); + this.orderClass(node); + this.orderStyle(node); + this.orderClass(otherNode); + this.orderStyle(otherNode); + if (node.attributes.length !== otherNode.attributes.length) { + return false; + } + for (var i = 0; i < node.attributes.length; i++) { + var attr = node.attributes[i]; + var otherAttr = otherNode.attributes[i]; + if (attr.name !== otherAttr.name || attr.value !== otherAttr.value) { + return false; + } + } + return true; + }, + /** + * Returns the number of leading breakable space in the textNode. + * Note: returns 0 if the node is not a textNode. + * + * @param {Node} + */ + countLeadingBreakableSpace: function (node) { + if (!dom.isText(node)) { + return 0; + } + var clone = $(node).clone()[0]; + var breakableSpace = this.removeExtremeBreakableSpace(clone, 0).start; + return breakableSpace === 1 ? 0 : breakableSpace; + }, + /** + * Returns the number of trailing breakable space in the textNode. + * Note: returns 0 if the node is not a textNode. + * + * @param {Node} node + */ + countTrailingBreakableSpace: function (node) { + if (!dom.isText(node)) { + return 0; + } + var clone = $(node).clone()[0]; + var breakableSpace = this.removeExtremeBreakableSpace(clone, 0).end; + return breakableSpace === 1 ? 0 : breakableSpace; + }, + /** + * Remove the dom between 2 points (respecting unbreakable rules). + * Returns an object: + * { + * node: pointA.node (updated if necessary), + * offset: pointA.offset (updated if necessary), + * changed: bool (whether changes were applied) + * } + * + * @param {Object} pointA + * @param {Node} pointA.node + * @param {Integer} pointA.offset + * @param {Object} pointB + * @param {Node} pointB.node + * @param {Integer} pointB.offset + * @returns {Object} {node, offset, changed} + */ + deleteBetween: function (pointA, pointB) { + var self = this; + if (pointB.node.childNodes[pointB.offset]) { + var firstLeaf = this.firstLeaf(pointB.node.childNodes[pointB.offset]); + pointB = this.makeRange(firstLeaf, 0); + } + if (pointB.node.tagName && pointB.node.tagName !== 'BR' && pointB.offset >= dom.nodeLength(pointB.node)) { + pointB = dom.nextPoint(pointB); + } + var changed; + var commonAncestor = dom.commonAncestor(pointA.node, pointB.node); + + var ecAncestor = dom.ancestor(pointB.node, function (node) { + return node === commonAncestor || self.options.isUnbreakableNode(node.parentNode); + }); + var next = this.splitTree(ecAncestor, pointB, { + nextText: true, + }); + + var scAncestor = dom.ancestor(pointA.node, function (node) { + return node === commonAncestor || self.options.isUnbreakableNode(node.parentNode); + }); + if (dom.isIcon(pointA.node)) { + pointA = dom.prevPoint(pointA); + } + this.splitTree(scAncestor, pointA, { + nextText: true, + }); + pointA.offset = dom.nodeLength(pointA.node); + + var nodes = []; + dom.nextPointUntil(pointA, function (point) { + if (point.node === next || !point.node) { + return true; + } + if (dom.isText(point.node) && point.offset) { + return; + } + var target = point.node.childNodes[point.offset] || point.node; + if (target === pointA.node || $.contains(target, pointA.node) || target === next || $.contains(target, next)) { + return; + } + if ( + nodes.indexOf(target) === -1 && !dom.ancestor(target, function (target) { + return nodes.indexOf(target) !== -1; + }) + ) { + nodes.push(target); + } + }); + $(nodes).remove(); + + changed = !!nodes.length; + var toMerge = changed && pointA.node.parentNode !== next.parentNode; + + + var point = this.makePoint(this.firstLeaf(next), 0); + if (nodes.length > 1 || nodes.length && !dom.isText(nodes[0])) { + point = this.removeEmptyInlineNodes(point); + } + + // Remove whole li/ul/ol if deleted all contents of li/ul/ol + var ul = dom.ancestor(next, function (n) { + return n.tagName === 'UL' || n.tagName === 'OL'; + }); + if (ul && next[dom.isText(next) ? 'textContent' : 'innerHTML'] === '' && pointA.node !== next.previousSibling) { + var toRemove = next; + while ( + toRemove !== ul && toRemove.parentNode && + !this.options.isUnbreakableNode(toRemove.parentNode) && + this.isBlankNode(toRemove.parentNode) + ) { + toRemove = toRemove.parentNode; + } + $(toRemove).remove(); + } + if (!$.contains(this.editable, pointA.node)) { + pointA = point; + } + + if (toMerge) { + pointA = this.deleteEdge(pointA.node, 'next') || pointA; + } + + return { + node: pointA.node, + offset: pointA.offset, + changed: changed, + }; + }, + /** + * Remove the edge between a node and its sibling + * (= merge the nodes, respecting unbreakable rules). + * + * @param {Node} node + * @param {String('next'|'prev')} direction + * @param {Boolean} doNotTryNonSim true to not try merging non-similar nodes + * @returns {Object} {node, offset} + */ + deleteEdge: function (node, direction, doNotTryNonSim) { + var prevOrNext = direction === 'prev' ? 'previousSibling' : 'nextSibling'; + var result = false; + var startN = node; + + if (node.tagName === 'BR' && node.nextSibling && !(dom.isText(node.nextSibling) && !this.isVisibleText(node.nextSibling))) { + node = node.nextSibling; + node = this.firstLeaf(node); + } + + var nodes = []; + var next; + while (node && node !== this.editable && !this.options.isUnbreakableNode(node)) { + nodes.push(node); + + next = node[prevOrNext]; + while (next && !next.tagName) { + if (!this.getRegex('char').test(next.textContent)) { + next = next[prevOrNext]; + continue; + } + break; + } + + if (next) { + break; + } + node = node.parentNode; + } + + if (next && next.tagName === 'TABLE') { + return this.makePoint(node, 0); + } + + var ifBrRemovedAndMerge = !_.filter(nodes, this.isNodeBlockType.bind(this)).length; + var brRemoved = false; + + var range = this.context.invoke('editor.createRange'); + + var spaceToRemove = []; + while ((node = nodes.pop())) { + next = node[prevOrNext]; + while (next && !next.tagName) { + if (!this.getRegex('char').test(next.textContent)) { + spaceToRemove.push(next); + next = next[prevOrNext]; + continue; + } + break; + } + if ( + !next || + !(node.tagName || next.tagName === 'BR') || + !next.tagName + ) { + continue; + } + + if (!brRemoved && next.tagName === 'BR' && (!next[prevOrNext] || this.compareNodes(node, next[prevOrNext]))) { + var newNext = next[prevOrNext]; + $(next).remove(); + next = newNext; + var offset = (next ? direction === 'prev' : direction === 'next') ? dom.nodeLength(next) : 0; + result = this.makePoint(next || node, offset); + if (!ifBrRemovedAndMerge) { + continue; + } + brRemoved = true; + ifBrRemovedAndMerge = false; + } + + if (!this.compareNodes(node, next)) { + continue; + } + _.each(spaceToRemove, function (space) { + $(space).remove(); + }); + spaceToRemove = []; + next = node[prevOrNext]; + var $next = $(next); + if (next.tagName) { + var textNode; + var nextTextNode; + var deep; + if (direction === 'prev') { + textNode = this.firstLeaf(node); + if (!textNode.tagName && !dom.ancestor(textNode, dom.isPre)) { + this.removeExtremeBreakableSpace(textNode); + range.so = range.eo = 0; + nextTextNode = this.lastLeaf(next); + if (!nextTextNode.tagName && !dom.ancestor(nextTextNode, dom.isPre)) { + this.removeExtremeBreakableSpace(nextTextNode); + } + } + deep = this.lastLeaf(next); + result = this.makePoint(deep, dom.nodeLength(deep)); + if ( + this.getRegex('char').test(node.textContent) || node.childElementCount > 1 || + node.firstElementChild && node.firstElementChild.tagName !== "BR" + ) { + $next.append($(node).contents()); + } + $(node).remove(); + } else { + nextTextNode = this.firstLeaf(next); + if (!nextTextNode.tagName && !dom.ancestor(nextTextNode, dom.isPre)) { + this.removeExtremeBreakableSpace(nextTextNode); + textNode = this.lastLeaf(node); + if (!textNode.tagName && !dom.ancestor(textNode, dom.isPre)) { + this.removeExtremeBreakableSpace(textNode); + range.so = range.eo = dom.nodeLength(node); + } + } + if (node.innerHTML.trim() === '<br>') { + $(node).contents().remove(); + } + deep = this.lastLeaf(node); + result = this.makePoint(deep, dom.nodeLength(deep)); + $(node).append($next.contents()); + $next.remove(); + } + continue; + } else if (!this.getRegex('char').test(next.textContent)) { + result = this.makePoint(node, direction === 'prev' ? 0 : dom.nodeLength(node)); + $next.remove(); + continue; + } + + break; + } + + if (!result && startN && !doNotTryNonSim) { + result = this.deleteNonSimilarEdge(startN, direction); + } + + return result; + }, + /** + * Find and delete the previous/next non-similar edge if possible. + * "Similar" means that they have the same tag, styles, classes and attributes. + * + * @param {Node} node + * @param {String} direction 'prev' or 'next' + * @returns {false|Object} {node, offset} + */ + deleteNonSimilarEdge: function (node, direction) { + var next = node[direction === 'next' ? 'nextSibling' : 'previousSibling']; + while ( + next && dom.isText(next) && + this.getRegexBlank({ + space: true, + invisible: true, + }).test(next.textContent) + ) { + next = next[direction === 'next' ? 'nextSibling' : 'previousSibling']; + } + + if (next) { + return; + } + + node = this.firstBlockAncestor(node); + + if (this.options.isUnbreakableNode(node)) { + return; + } + + var point = this.makePoint(node, direction === 'prev' ? 0 : dom.nodeLength(node)); + var otherBlock = this.findNextBlockToMerge(point.node, direction); + + if (!otherBlock) { + return; + } + + var blockToMergeFrom = direction === 'next' ? otherBlock : point.node; + var blockToMergeInto = direction === 'next' ? point.node : otherBlock; + + // empty tag are removed + if ( + this.getRegexBlank({ + space: true, + newline: true, + }).test(blockToMergeInto.textContent) && + !$(blockToMergeInto).find('.fa').length && $(blockToMergeInto).find('br').length <= 1 + ) { + $(blockToMergeInto).remove(); + return this.makePoint(this.firstLeaf(blockToMergeFrom), 0); + } + + return this.mergeNonSimilarBlocks(blockToMergeFrom, blockToMergeInto); + }, + /** + * Deletes the contents of the selected DOM. + * + * @returns {Boolean} true if there was a selection to delete + */ + deleteSelection: function () { + var range = this.context.invoke('editor.createRange'); + if (range.isCollapsed()) { + return false; + } + var point = this.deleteBetween(range.getStartPoint(), range.getEndPoint()); + point = this.fillEmptyNode(point); + + range = this.context.invoke('editor.setRange', point.node, point.offset); + range = range.select(); + + this.editable.normalize(); + this.context.invoke('editor.saveRange'); + + // remove tooltip when remove DOM nodes + $('body > .tooltip').tooltip('hide'); + + return true; + }, + /** + * Fill up an empty node so as to allow the carret to go inside it. + * A block node will be filled with a <br>, with the offset before it. + * An inline node will be filled with two zero-width spaces, with the offset in between the two. + * Returns the given point (with the completed node and the updated offset). + * + * @param {Object} point {node, offset} + * @returns {Object} {node, offset} + */ + fillEmptyNode: function (point) { + if ( + !point.node.tagName && this.getRegexBlank({ + space: true, + invisible: true, + nbsp: true, + }).test(point.node.parentNode.innerHTML) + ) { + point.node = point.node.parentNode; + point.offset = 0; + } + if ( + point.node.tagName && point.node.tagName !== 'BR' && + this.getRegexBlank({ + space: true, + invisible: true, + nbsp: true, + }).test(point.node.innerHTML) + ) { + var text = this.document.createTextNode(''); + point.node.innerHTML = ''; + point.node.appendChild(text); + point.node = text; + point.offset = 0; + } + if (point.node.parentNode.innerHTML === '') { + if (this.isNodeBlockType(point.node.parentNode)) { + var node = point.node.parentNode; + node.innerHTML = '<br/>'; + point.node = node.firstChild; + point.offset = 0; + } else { + point.node.textContent = '\u200B\u200B'; + point.offset = 1; + } + } + return point; + }, + /** + * Get the "format" ancestors list of nodes. + * In this context, a "format" node is understood as + * an editable block or an editable element expecting text + * (eg.: p, h1, span). + * + * @param {Node[]} nodes + * @returns {Node[]} + */ + filterFormatAncestors: function (nodes) { + var self = this; + var selectedNodes = []; + _.each(this.filterLeafChildren(nodes), function (node) { + var ancestor = dom.ancestor(node, function (node) { + return dom.isCell(node) || ( + !self.options.isUnbreakableNode(node) && + (self.isFormatNode(node) || self.isNodeBlockType(node)) + ) && self.editable !== node; + }); + if (!ancestor) { + ancestor = node; + } + if (dom.isCell(ancestor)) { + ancestor = node; + } + if (ancestor && selectedNodes.indexOf(ancestor) === -1) { + selectedNodes.push(ancestor); + } + }); + return selectedNodes; + }, + /** + * Get the "leaf" children of a list of nodes. + * In this context, a "leaf" is understood as + * either a text node or a node that doesn't expect text contents. + * + * @param {Node[]} nodes + * @returns {Node[]} + */ + filterLeafChildren: function (nodes) { + var self = this; + return _.compact(_.map(nodes, function (node) { + if (node.firstChild) { + node = node.firstChild; + } + if ( + node.tagName === "BR" || + self.isVisibleText(node) || + dom.isFont(node) || + dom.isImg(node) || + dom.isDocument(node) + ) { + return node; + } + })); + }, + /** + * Find the previous/next non-similar block to merge with. + * "Similar" means that they have the same tag, styles, classes and attributes. + * + * @param {Node} node + * @param {String} direction 'prev' or 'next + * @returns {false|Node} + */ + findNextBlockToMerge: function (node, direction) { + var self = this; + var startNode = node; + var mergeableTags = this.options.styleTags.join(', ') + ', li'; + var blockToMerge = false; + + var li = dom.ancestor(node, function (n) { + return n !== node && self.isNodeBlockType(n) || dom.isLi(n); + }); + li = li && dom.isLi(li) ? li : undefined; + if (li && direction === 'next') { + if (li.nextElementSibling) { + node = li; + } else { + node = dom.ancestor(node, function (n) { + return ((n.tagName === 'UL' || n.tagName === 'OL') && n.nextElementSibling); + }); + } + } + + if (!node || !node[direction === 'next' ? 'nextElementSibling' : 'previousElementSibling']) { + return false; + } + + node = node[direction === 'next' ? 'nextElementSibling' : 'previousElementSibling']; + + var ulFoldedSnippetNode = dom.ancestor(node, function (n) { + return $(n).hasClass('o_ul_folded'); + }); + var ulFoldedSnippetStartNode = dom.ancestor(startNode, function (n) { + return $(n).hasClass('o_ul_folded'); + }); + if ( + (this.options.isUnbreakableNode(node) && (!ulFoldedSnippetNode || ulFoldedSnippetNode === this.editable)) && + this.options.isUnbreakableNode(startNode) && (!ulFoldedSnippetStartNode || ulFoldedSnippetStartNode === this.editable) + ) { + return false; + } + + node = this.firstBlockAncestor(node); + + li = dom.ancestor(node, function (n) { + return n !== node && self.isNodeBlockType(n) || dom.isLi(n); + }); + li = li && dom.isLi(li) ? li : undefined; + node = li || node; + + if (node.tagName === 'UL' || node.tagName === 'OL') { + node = node[direction === 'next' ? 'firstElementChild' : 'lastElementChild']; + } + + if (this.options.isUnbreakableNode(node)) { + return false; + } + + if (node === startNode || $(node).has(startNode).length || $(startNode).has(node).length) { + return false; + } + + var $mergeable = $(node).find('*').addBack() + .filter(mergeableTags) + .filter(function (i, n) { + if (!(n.tagName === 'LI' && $(n).find(mergeableTags).length)) { + return n; + } + }); + if ($mergeable.length) { + blockToMerge = $mergeable[direction === 'next' ? 'first' : 'last']()[0] || false; + } + + return blockToMerge; + }, + /** + * Get the first ancestor of a node, that is of block type (or itself). + * + * @param {Node} node + * @returns {Node} + */ + firstBlockAncestor: function (node) { + var self = this; + return dom.ancestor(node, function (n) { + return self.isNodeBlockType(n); + }); + }, + /** + * Get the first leaf of a node, that is editable and not a media. + * In this context, a leaf node is understood as a childless node. + * + * @param {Node} node + * @returns {Node} + */ + firstLeaf: function (node) { + while (node.firstChild && !dom.isMedia(node) && this.options.isEditableNode(node)) { + node = node.firstChild; + } + return node; + }, + /** + * Get the first leaf of a node, that is an element and not a BR. + * In this context, a leaf node is understood as a childless node. + * + * @param {Node} node + * @returns {Node} node + */ + firstNonBRElementLeaf: function (node) { + while (node.firstElementChild && node.firstElementChild.tagName !== 'BR') { + node = node.firstElementChild; + } + return node; + }, + /** + * Returns the node targeted by a path + * + * @param {Object[]} list of object (tagName, offset) + * @returns {Node} + */ + fromPath: function (path) { + var node = this.editable; + var to; + path = path.slice(); + while ((to = path.shift())) { + node = _.filter(node.childNodes, function (node) { + return !to.tagName && node.tagName === 'BR' || node.tagName === to.tagName; + })[to.offset]; + } + return node; + }, + /** + * Returns (and creates if necessary) a regular expression. + * If a regular expression with the given name exists, simply returns it. + * Otherwise, creates a new one with the given name, exp and flag. + * + * @param {String} name + * @param {String} [flag] optional + * @param {String} [exp] optional + * @returns {RegExp} + */ + getRegex: function (name, flag, exp) { + var flagName = flag || 'noflag'; + flag = flag || ''; + // If the regular expression exists, but not with this flag: + // retrieve whichever version of it and apply the new flag to it, + // then save that new version in the `regex` object. + if (this.regex[name] && !this.regex[name][flagName]) { + if (exp) { + console.warn("A regular expression already exists with the name: " + name + ". The expression passed will be ignored."); + } + var firstVal = this.regex[name][Object.keys(this.regex[name])[0]]; + this.regex[name][flagName] = new RegExp(firstVal, flag); + } else if (!this.regex[name]) { + // If the regular expression does not exist: + // save it into the `regex` object, with the name, expression + // and flag passed as arguments (if any). + if (!exp) { + throw new Error("Cannot find a regular expression with the name " + name + ". Pass an expression to create it."); + } + this.regex[name] = {}; + this.regex[name][flagName] = new RegExp(exp, flag); + } + return this.regex[name][flagName]; + }, + /** + * Returns (and creates if necessary) a regular expression + * targetting a string made ONLY of some combination of the + * characters enabled with options. + * If a regular expression with the given options exists, simply returns it. + * eg: getRegexBlank({space: true, nbsp: true}) => /^[\s\u00A0]*$/ + * + * @param {Object} [options] optional + * @param {Boolean} options.not ^ (not all that follows) + * @param {Boolean} options.space \s (a whitespace) + * @param {Boolean} options.notspace \S (not a whitespace) + * @param {Boolean} options.nbsp \u00A0 (a non-breakable space) + * @param {Boolean} options.invisible \u200B (a zero-width character) + * @param {Boolean} options.newline \n|\r (a new line or a carriage return) + * @param {Boolean} options.atLeastOne + (do not target blank strings) + * @returns {RegExp} + */ + getRegexBlank: function (options) { + options = options || {}; + var charMap = { + notspace: { + name: 'NotSpace', + exp: '\\S', + }, + space: { + name: 'Space', + exp: '\\s', + }, + nbsp: { + name: 'Nbsp', + exp: '\\u00A0', + }, + invisible: { + name: 'Invisible', + exp: '\\u200B', + }, + newline: { + name: 'Newline', + exp: '\\n\\r', + }, + }; + var name = 'only'; + var exp = ''; + var atLeastOne = options.atLeastOne; + options.atLeastOne = false; + + // Build the expression and its name + if (options.not) { + name += 'Not'; + exp += '^'; + options.not = false; + } + _.each(options, function (value, key) { + if (value && charMap[key]) { + name += charMap[key].name; + exp += charMap[key].exp; + } + }); + + exp = '^[' + exp + ']' + (atLeastOne ? '+' : '*') + '$'; + name += atLeastOne ? 'One' : ''; + return this.getRegex(name, undefined, exp); + }, + /** + * Returns a list of all selected nodes in the range. + * + * @returns {Node []} + */ + getSelectedNodes: function () { + var range = this.context.invoke('editor.createRange'); + if (!range.isCollapsed()) { + if (range.so && !range.sc.tagName) { + if (range.sc === range.ec) { + range.sc = range.ec = range.sc.splitText(range.so); + range.eo -= range.so; + } else { + range.sc = range.sc.splitText(range.so); + } + range.so = 0; + } + if (!range.ec.tagName && range.eo !== dom.nodeLength(range.ec) && !range.ec.tagName) { + range.ec.splitText(range.eo); + } + range.select(); + } + var res = [range.sc]; + var point = range.getStartPoint(); + var prevNode = range.sc; + dom.nextPointUntil(point, function (pt) { + if (pt.node !== prevNode) { + var ok = true; + // Return only the smallest traversed children + _.each(res, function (n, i) { + // If pt.node is a child of res[i], replace it + if (dom.listAncestor(pt.node).indexOf(n) !== -1) { + res[i] = pt.node; + ok = false; + } else if (dom.listAncestor(n).indexOf(pt.node) !== -1) { + // Inversely, skip parents of res[i] + ok = false; + } + }); + if (ok) { + res.push(pt.node); + } + } + prevNode = pt.node; + return pt.node === range.ec; + }); + return _.uniq(res); + }, + /** + * Returns a list of all selected text nodes in the range. + * + * @returns {Node []} + */ + getSelectedText: function () { + var self = this; + var selectedText = _.filter(this.getSelectedNodes(), function (node) { + node = self.firstLeaf(node); + return self.isVisibleText(node); + }); + return _.uniq(selectedText); + }, + /** + * Returns true if the value contains jinja logic. + * + * @returns {Boolean} + */ + hasJinja: function (value) { + return this.getRegex('jinja').test(value); + }, + /** + * Inserts a block node (respecting the rules of unbreakable nodes). + * In order to insert the node, the DOM tree is split at the carret position. + * If there is a selection, it is deleted first. + * + * @param {Node} node + */ + insertBlockNode: function (node) { + var self = this; + var range = this.context.invoke('editor.createRange'); + range = range.deleteContents(); + var point = range.getStartPoint(); + var unbreakable = point.node; + if (!this.options.isUnbreakableNode(point.node)) { + unbreakable = dom.ancestor(point.node, function (node) { + return self.options.isUnbreakableNode(node.parentNode) || node === self.editable; + }) || point.node; + } + + if (unbreakable === point.node && !point.offset && point.node.tagName !== 'P') { + if (point.node.innerHTML === '<br>') { + $(point.node.firstElementChild).remove(); + } + if (point.node.tagName === "BR") { + $(point.node).replaceWith(node); + } else { + point.node.append(node); + } + return; + } + if (!this.options.isUnbreakableNode(point.node)) { + var tree = dom.splitTree(unbreakable, point, { + isSkipPaddingBlankHTML: true, + isNotSplitEdgePoint: true, + }); + if ((!tree || $.contains(tree, range.sc)) && (point.offset || point.node.tagName)) { + tree = tree || dom.ancestor(point.node, function (node) { + return self.options.isUnbreakableNode(node.parentNode); + }); + $(tree).after(node); + } else { + $(tree).before(node); + } + } else { + // prevent unwrapped text in unbreakable + if (dom.isText(unbreakable)) { + $(unbreakable).wrap(this.document.createElement('p')); + unbreakable.splitText(point.offset); + unbreakable = unbreakable.parentNode; + point.offset = 1; + } + $(unbreakable.childNodes[point.offset]).before(node); + } + if (range.sc.innerHTML === '<br>') { + var clone = range.sc.cloneNode(true); + if (node.previousSibling === range.sc) { + $(node).after(clone); + } else if (node.nextSibling === range.sc) { + $(node).before(clone); + } + } + }, + /** + * Inserts a string as a text node in the DOM. + * If the range is on a text node, splits the text node first. + * Otherwise just inserts the text node. + * Wraps it in a P if needed. + * + * @param {String} text + */ + insertTextInline: function (text) { + if (text === " ") { + text = "\u00A0"; + } + this.editable.normalize(); + this.deleteSelection(); + + var range = this.context.invoke('editor.createRange'); + + + if (!range.sc.tagName && range.sc.textContent.match(/\S/)) { + var before = range.sc.textContent.slice(0, range.so); + var after = range.sc.textContent.slice(range.so); + + if ( + (before.length || after.length) && + (!before.length || before[before.length - 1] === ' ') && + (!after.length || after[0] === ' ') + ) { + var startSpace = this.getRegex('startSpace'); + var endSpace = this.getRegex('endSpace'); + before = before.replace(endSpace, '\u00A0'); + after = after.replace(startSpace, '\u00A0'); + range.sc.textContent = before + after; + if (range.so > before.length) { + range.so = range.eo = before.length; + } + range.select(); + } + } + + + range = range.deleteContents(); + range = this._standardizeRangeOnEdge(range); + var textNode = this._insertTextNodeInEditableArea(range, text); + + // if the text node can't be inserted in the dom (not editable area) do nothing + if (!textNode) { + return; + } + + this.secureExtremeSingleSpace(range.sc); + + this._wrapTextWithP(textNode); + + range = this.context.invoke('editor.setRange', textNode, text.length); + range.select(); + this.context.invoke('editor.saveRange'); + + // Clean up and make leading/trailing/multiple space visible + this.normalize(); + range = this.context.invoke('editor.createRange'); + var reStartBlanks = this.getRegex('startBlanks', '', '^([\\s\\u00A0\\u200B]*)'); + var reEndBlanks = this.getRegex('endBlanks', '', '([\\s\\u00A0\\u200B]*)$'); + var reAllNBSP = /\u00A0/g; + var reMultipleSpace = /(\s){2,}/; + + var startBlanks = range.sc.textContent.match(reStartBlanks)[0] || ''; + var endBlanks = range.sc.textContent.match(reEndBlanks)[0] || ''; + var trimmed = range.sc.textContent.replace(reStartBlanks, '').replace(reEndBlanks, ''); + // Remove the single inner nbsp's and replace the multiple inner spaces with nbsp's + var cleanContents = trimmed.replace(reAllNBSP, ' ') + .replace(reMultipleSpace, function (space) { + return Array(space.length + 1).join('\u00A0'); + }); + // Keep the leading/trailing whitespace, nbsp's and zero-width chars + range.sc.textContent = startBlanks + cleanContents + endBlanks; + + this._removeInvisibleChar(range); + + range.select(); + this.context.invoke('editor.saveRange'); + }, + /** + * Returns true if the node is a text node containing nothing + * + * @param {Node} node + * @returns {Boolean} + */ + isBlankText: function (node) { + return dom.isText(node) && + this.getRegexBlank({ + not: true, + notspace: true, + nbsp: true, + invisible: true, + }) + .test(node.textContent); + }, + /** + * Returns true if the node is blank. + * In this context, a blank node is understood as + * a node expecting text contents (or with children expecting text contents) + * but without any. + * + * @param {Node} node + * @returns {Boolean} + */ + isBlankNode: function (node) { + if (dom.isVoid(node) || dom.isIcon(node)) { + return false; + } + if (this.getRegexBlank({ + space: true, + }).test(node[dom.isText(node) ? 'textContent' : 'innerHTML'])) { + return true; + } + if (node.childNodes.length && _.all(node.childNodes, this.isBlankNode.bind(this))) { + return true; + } + return false; + }, + /** + * Returns true if the point is on the left/right edge of the first + * previous/next point with the given tag name (skips insignificant nodes). + * + * @param {Object} point + * @param {String} tagName + * @param {String('left'|'right')} side + * @returns {Boolean} + */ + isEdgeOfTag: function (point, tagName, side) { + var method = side === 'left' ? 'isLeftEdgePoint' : 'isRightEdgePoint'; + var prevOrNext = side === 'left' ? 'prev' : 'next'; + var newPt; + var first = true; + while (point && point.node.tagName !== tagName) { + newPt = this.skipNodes(point, prevOrNext, function (pt) { + return pt.node.tagName === tagName && dom[method](pt); + }); + if (newPt.node.tagName === tagName || newPt.node.tagName === 'BR') { + point = newPt; + break; + } + if (newPt === point && (!first || dom.isText(point.node) && !dom[method](point))) { + break; + } + point = dom[prevOrNext + 'Point'](newPt); + first = false; + } + if (!point) { + return false; + } + var ancestor = dom.ancestor(point.node, function (n) { + return n.tagName === tagName; + }); + return !!(ancestor && dom[method + 'Of'](point, ancestor)); + }, + /** + * Returns true if the node is a "format" node. + * In this context, a "format" node is understood as + * an editable block or an editable element expecting text + * (eg.: p, h1, span). + * + * @param {Node} node + * @returns {Boolean} + */ + isFormatNode: function (node) { + return node.tagName && this.options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1; + }, + /** + * Returns true if the node is within a table. + * + * @param {Node} node + * @returns {Boolean} + */ + isInTable: function (node) { + return !!dom.ancestor(node, function (n) { + return n.tagName === 'TABLE'; + }); + }, + /** + * Returns true if the point is on the left edge of a block node + * (skips insignificant nodes). + * + * @param {Object} point + * @returns {Boolean} + */ + isLeftEdgeOfBlock: function (point) { + point = this.skipNodes(point, 'prev'); + return dom.isLeftEdgePointOf(point, this.firstBlockAncestor(point.node)); + }, + /** + * Returns true if the point is on the left edge of the first + * previous point with the given tag name (skips insignificant nodes). + * + * @param {Object} point + * @param {String} tagName + * @returns {Boolean} + */ + isLeftEdgeOfTag: function (point, tagName) { + return this.isEdgeOfTag(point, tagName, 'left'); + }, + /** + * Returns true if the node is a block. + * + * @param {Node} node + * @returns {Boolean} + */ + isNodeBlockType: function (node) { + if (dom.isText(node)) { + return false; + } + var display = this.window.getComputedStyle(node).display; + // All inline elements have the word 'inline' in their display value, except 'contents' + return display.indexOf('inline') === -1 && display !== 'contents'; + }, + /** + * Returns true if the point is on the right edge of the first + * next point with the given tag name (skips insignificant nodes). + * + * @param {Object} point + * @param {String} tagName + * @returns {Boolean} + */ + isRightEdgeOfTag: function (point, tagName) { + return this.isEdgeOfTag(point, tagName, 'right'); + }, + /** + * Returns true if point should be ignored. + * This is generally used for trying to figure out if the point is an edge point. + * + * @param {Object} point + * @param {String} direction ('prev' or 'next') + * @param {Object} options + * @param {Boolean} options.noSkipBlankText true to not skip blank text + * @param {Boolean} options.noSkipSingleBRs true to not skip single BRs + * @param {Boolean} options.noSkipExtremeBreakableSpace true to not skip leading/trailing breakable space + * @param {Boolean} options.noSkipParent true to not skip to leaf nodes or offset 0 + * @param {Boolean} options.noSkipSibling true to not skip if on edge and sibling is skippable + * @returns {Boolean} + */ + isSkippable: function (point, direction, options) { + options = options || {}; + var isEdge = direction === 'prev' ? dom.isLeftEdgePoint(point) : dom.isRightEdgePoint(point); + + // skip blank text nodes + if ( + !options.noSkipBlankText && + this.isBlankText(point.node) + ) { + return true; + } + // skip single BRs + if ( + !options.noSkipSingleBRs && + point.node.tagName === 'BR' && + (!point.node.previousSibling || this.isBlankText(point.node.previousSibling)) && + (!point.node.nextSibling || this.isBlankText(point.node.nextSibling)) + ) { + return true; + } + // skip leading/trailing breakable space + if ( + !options.noSkipExtremeBreakableSpace && + (direction === 'prev' && !isEdge && point.offset <= this.countLeadingBreakableSpace(point.node) || + direction === 'next' && point.offset > dom.nodeLength(point.node) - this.countTrailingBreakableSpace(point.node)) + ) { + return true; + } + // skip to leaf node or edge + var node = direction === 'prev' ? point.node.childNodes[0] : point.node.childNodes[point.node.childNodes.length - 1]; + var offset = direction === 'prev' ? 0 : dom.nodeLength(node); + if ( + !options.noSkipParent && + !isEdge && point.node.childNodes.length && + this.isSkippable(this.makePoint(node, offset), direction, options) + ) { + return true; + } + // skip if on edge and sibling is skippable + var sibling = direction === 'prev' ? point.node.previousSibling : point.node.nextSibling; + offset = direction === 'prev' ? 0 : dom.nodeLength(sibling); + if ( + !options.noSkipSibling && + isEdge && sibling && + this.isSkippable(this.makePoint(sibling, offset), direction, _.defaults({ + noSkipSibling: true, + }, options)) + ) { + return true; + } + return false; + }, + /** + * Returns true if the node is a text node with visible text. + * + * @param {Node} node + * @returns {Boolean} + */ + isVisibleText: function (node) { + return !node.tagName && this.getRegex('char').test(node.textContent); + }, + /** + * Get the last leaf of a node, that is editable and not a media. + * In this context, a leaf node is understood as a childless node. + * + * @param {Node} node + * @returns {Node} + */ + lastLeaf: function (node) { + while (node.lastChild && !dom.isMedia(node) && this.options.isEditableNode(node)) { + node = node.lastChild; + } + return node; + }, + /** + * Returns a Point object from a node and an offset. + * + * @param {Node} node + * @param {Number} offset + */ + makePoint: function (node, offset) { + return { + node: node, + offset: offset, + }; + }, + /** + * Merges mergeFromBlock into mergeIntoBlock, respecting the rules of unbreakable. + * + * @param {Node} mergeFromBlock block to merge from + * @param {Node} mergeIntoBlock block to merge into + * @returns {Object} {node, offset} + */ + mergeNonSimilarBlocks: function (mergeFromBlock, mergeIntoBlock) { + var point; + var mergeableTags = this.options.styleTags.join(', ') + ', li'; + var $contents = $(mergeFromBlock).find('*').addBack() + .filter(mergeableTags) + .filter(function (i, n) { + if (!(n.tagName === 'LI' && $(n).find(mergeableTags).length)) { + return n; + } + }).contents(); + var containsUnbreakables = !!$contents.filter(this.options.isUnbreakable).length; + + if ($contents.length && !containsUnbreakables) { + if (dom.isText($contents[0])) { + this.removeExtremeBreakableSpace($contents[0]); + } + var $lastContents = $(mergeIntoBlock).contents().last(); + if (!($contents.length === 1 && $contents[0].tagName === 'BR')) { + if (mergeIntoBlock.innerHTML.trim() === '<br>') { + $(mergeIntoBlock).contents().remove(); + $(mergeIntoBlock).append($contents); + $lastContents = false; + } else { + $lastContents.after($contents); + } + } + while (mergeFromBlock.parentNode && this.isBlankNode(mergeFromBlock.parentNode)) { + mergeFromBlock = mergeFromBlock.parentNode; + } + $(mergeFromBlock).remove(); + + point = {}; + if ($lastContents && $lastContents.length) { + point = this.makePoint($lastContents[0], dom.nodeLength($lastContents[0])); + } else { + point = this.makePoint($contents[0], 0); + } + + point = this.deleteEdge(point.node, 'next', true) || point; + } + return point; + }, + /** + * Normalize the DOM and range. + */ + normalize: function () { + this.editable.normalize(); + var range = this.context.invoke('editor.createRange'); + var rangeN = range.normalize(); + + // summernote's normalize function fails when br in text, + // and targets the br instead of the point just after br. + var point = rangeN.getStartPoint(); + if (point.node.tagName === "BR") { + point = dom.nextPoint(point); + } + if (point.node.tagName && point.node.childNodes[point.offset]) { + point = dom.nextPoint(point); + } + if (point.node.tagName === "BR") { + point = dom.nextPoint(point); + } + if (point.node !== range.sc || point.offset !== range.so) { + range = this.context.invoke('editor.setRange', point.node, point.offset); + range.select(); + } + }, + /** + * Simulate a do_notify by notifying the user through a dialog. + * + * @param {String} title + * @param {String} content + */ + notify: function (title, content) { + var $notif = $('<p>' + content + '</p>'); + new Dialog(this, { + title: title, + size: 'medium', + $content: $notif, + }).open(); + }, + /** + * Reorders the classes in the node's class attribute and returns it. + * + * @param {Node} node + * @returns {String} + */ + orderClass: function (node) { + var className = node.getAttribute && node.getAttribute('class'); + if (!className) { + return null; + } + className = className.replace(this.getRegex('spaceOrNewline', 'g'), ' ') + .replace(this.getRegex('startAndEndSpace', 'g'), '') + .replace(this.getRegex('space', 'g'), ' '); + className = className.replace('o_default_snippet_text', '') + .replace('o_checked', ''); + if (!className.length) { + node.removeAttribute("class"); + return null; + } + className = className.split(" "); + className.sort(); + className = className.join(" "); + node.setAttribute('class', className); + return className; + }, + /** + * Reorders the styles in the node's style attributes and returns it. + * + * @param {Node} node + * @returns {String} + */ + orderStyle: function (node) { + var style = node.getAttribute('style'); + if (!style) { + return null; + } + style = style.replace(this.getRegex('spaceOrNewline'), ' ') + .replace(this.getRegex('startAndEndSemicolon', 'g'), '') + .replace(this.getRegex('semicolon', 'g'), ';'); + if (!style.length) { + node.removeAttribute("style"); + return null; + } + style = style.split(";"); + style.sort(); + style = style.join("; ") + ";"; + node.setAttribute('style', style); + return style; + }, + /** + * Returns the path from the editable node to the given node. + * + * @param {Node} node + * @returns {Object[]} list of objects (tagName, offset) + */ + path: function (node) { + var path = []; + while (node && node !== this.editable) { + var tagName = node.tagName; + path.unshift({ + tagName: tagName, + offset: _.filter(node.parentNode.childNodes, function (node) { + return node.tagName === tagName; + }).indexOf(node), + }); + node = node.parentNode; + } + return path; + }, + /** + * Removes all attributes without a value from the given node. + * + * @param {Node} node + * @returns {Node} + */ + removeBlankAttrs: function (node) { + _.each([].slice.call(node.attributes), function (attr) { + if (!attr.value) { + node.removeAttribute(attr.name); + } + }); + return node; + }, + /** + * Remove a node's direct blank siblings, if any. + * Eg: Text<i></i>Node<b></b>Text => TextNodeText + * + * @param {Node} node + */ + removeBlankSiblings: function (node) { + var isAfterBlank = node.previousSibling && this.isBlankNode(node.previousSibling); + if (isAfterBlank) { + $(node.previousSibling).remove(); + } + var isBeforeBlank = node.nextSibling && this.isBlankNode(node.nextSibling) + if (isBeforeBlank) { + $(node.nextSibling).remove(); + } + }, + /** + * Removes the block target and joins its siblings. + * + * @param {Node} target + * @param {Boolean} doNotInsertP true to NOT fill an empty unbreakable with a p element. + * @returns {Object} {node, offset} + */ + removeBlockNode: function (target, doNotInsertP) { + var self = this; + var check = function (point) { + if (point.node === target) { + return false; + } + return !point.node || self.options.isEditableNode(point.node) && + (point.node.tagName === "BR" || self.isVisibleText(point.node)); + }; + var parent = target.parentNode; + var offset = [].indexOf.call(parent.childNodes, target); + var deleteEdge = 'next'; + var point = dom.prevPointUntil(this.makePoint(target, 0), check); + if (!point || !point.node) { + deleteEdge = 'prev'; + point = dom.nextPointUntil(this.makePoint(target, 0), check); + } + + $(target).remove(); + + if ( + point && (deleteEdge === 'prev' && point.offset) || + deleteEdge === 'next' && point.offset === dom.nodeLength(point.node) + ) { + point = this.deleteEdge(point.node, deleteEdge) || point; + } + + $(parent).contents().filter(function () { + return dom.isText(this) && self.getRegexBlank({ + atLeastOne: true, + invisible: true, + }).test(this.textContent); + }).remove(); + + var br; + if (parent.innerHTML === '') { + br = this.document.createElement('br'); + $(parent).append(br); + point = this.makePoint(parent, 0); + } + + if (!doNotInsertP && this.getRegexBlank({ + space: true, + invisible: true, + }).test(parent.innerHTML)) { + br = this.document.createElement('br'); + if (this.options.isUnbreakableNode(parent) && parent.tagName !== "TD") { + var p = this.document.createElement('p'); + $(p).append(br); + $(parent).append(p); + } else { + $(parent).append(br); + } + point = this.makePoint(br.parentNode, 0); + } + + if (point && point.node.tagName === "BR" && point.node.parentNode) { + point = { + node: point.node.parentNode, + offset: [].indexOf.call(point.node.parentNode.childNodes, point.node), + }; + } + + return point || this.makePoint(parent, offset); + }, + /** + * Removes the empty inline nodes around the point, and joins its siblings. + * + * @param {Object} point {node, offset} + * @returns {Object} {node, offset} + */ + removeEmptyInlineNodes: function (point) { + var node = point.node; + if (!point.node.tagName && !point.node.textContent.length) { + node = node.parentNode; + if ($(node).hasClass('o_default_snippet_text')) { + // for default snippet value + return point; + } + } + var prev; + var next; + while ( + node.tagName !== 'BR' && + (node.tagName ? node.innerHTML : node.textContent) === '' && + !this.isNodeBlockType(node) && + this.options.isEditableNode(node.parentNode) && + (!node.attributes || !node.attributes.contenteditable) && + !dom.isMedia(node) + ) { + prev = node.previousSibling; + next = node.nextSibling; + point = { + node: node.parentNode, + offset: [].indexOf.call(node.parentNode.childNodes, node) + }; + $(node).remove(); + node = point.node; + } + if (next && !next.tagName) { + if (/^\s+[^\s<]/.test(next.textContent)) { + next.textContent = next.textContent.replace(this.getRegex('startSpace'), '\u00A0'); + } + } + if (prev) { + if (!prev.tagName) { + if (/[^\s>]\s+$/.test(prev.textContent)) { + prev.textContent = prev.textContent.replace(this.getRegex('endSpace'), ' '); + } + } + point = this.makePoint(prev, dom.nodeLength(prev)); + } + return point; + }, + /** + * Removes any amount of leading/trailing breakable space from a text node. + * Returns how many characters were removed at the start + * and at the end of the text node. + * + * @param {Node} textNode + * @param {Boolean} secureExtremeties (defaults to true) + * @returns {Object} removed {start, end} + */ + removeExtremeBreakableSpace: function (textNode, secureExtremeties) { + if (arguments.length === 1) { + secureExtremeties = true; + } + if (secureExtremeties) { + this.secureExtremeSingleSpace(textNode); + } + var removed = { + start: 0, + end: 0, + }; + textNode.textContent = textNode.textContent.replace(this.getRegex('startNotChar'), function (toRemove) { + removed.start = toRemove.length; + return ''; + }); + textNode.textContent = textNode.textContent.replace(this.getRegex('endNotChar'), function (toRemove) { + removed.end = toRemove.length; + return ''; + }); + return removed; + }, + /** + * Makes the leading/trailing single space of a node non breakable (nbsp). + * + * @param {Node} node + */ + secureExtremeSingleSpace: function (node) { + if (this.getRegex('endSingleSpace').test(node.textContent)) { + // if the text ends with a single space, make it insecable + node.textContent = node.textContent.substr(0, node.textContent.length - 1) + '\u00A0'; + } + if (this.getRegex('startSingleSpace').test(node.textContent)) { + // if the text starts with a single space, make it insecable + node.textContent = '\u00A0' + node.textContent.substr(1, node.textContent.length); + } + }, + /** + * Skips points to ignore (generally for trying to figure out if edge point). + * Returns the resulting point. + * + * @param {Object} point + * @param {String} direction ('prev' or 'next') + * @param {function} pred (extra condition to stop at) + * @param {Object} options + * @param {Boolean} options.noSkipBlankText true to not skip blank text + * @param {Boolean} options.noSkipSingleBRs true to not skip single BRs + * @param {Boolean} options.noSkipExtremeBreakableSpace true to not skip leading/trailing breakable space + * @param {Boolean} options.noSkipParent true to not skip to leaf nodes or offset 0 + * @param {Boolean} options.noSkipSibling true to not skip if on edge and sibling is skippable + * @returns {Object} {node, offset} + */ + skipNodes: function (point, direction, pred, options) { + var self = this; + if (arguments.length === 3 && !_.isFunction(arguments[2])) { + // allow for passing options and no pred function + options = _.clone(pred); + pred = null; + } + options = options || {}; + return dom[direction + 'PointUntil'](point, function (pt) { + return !self.isSkippable(pt, direction, options) || pred && pred(pt); + }); + }, + /** + * Split the DOM tree at the node's start and end points. + * + * @param {Node} node + */ + splitAtNodeEnds: function (node) { + if (!node.parentNode) { + return; + } + var startPoint = this.makePoint(node, 0); + var endPoint = this.makePoint(node, dom.nodeLength(node)); + var splitOptions = { + isSkipPaddingBlankHTML: true, + }; + this.splitTree(node.parentNode, startPoint, splitOptions); + this.splitTree(node.parentNode, endPoint, splitOptions); + // Splitting at ends may create blank nodes (because of dom.splitTree) so let's clean it up: + this.removeBlankSiblings(node.parentNode); + }, + /** + * Split the DOM tree at the point + * + * @param {Node} root - split root + * @param {BoundaryPoint} point {node, offset} + * @param {Object} [options] + * @param {Boolean} [options.nextText] - default: false + * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false + * @param {Boolean} [options.isNotSplitEdgePoint] - default: false + * @returns {Node} right node of boundary point + */ + splitTree: function (root, point, options) { + var nextText; + if (options && options.nextText && !point.node.tagName) { + nextText = point.node.splitText(point.offset); + } + var emptyText = false; + if (!point.node.tagName && point.node.textContent === "") { + emptyText = true; + point.node.textContent = '\u200B'; + point.offset = 1; + } + var next = dom.splitTree(root, point, options); + if (emptyText) { + point.node.textContent = ''; + } + var result = nextText || next || point.node; + var att = nextText ? 'textContent' : 'innerHTML'; + if (/^\s+([^\s<])/.test(result[att])) { + result[att] = result[att].replace(this.getRegex('startSpace'), '\u00A0'); + } + return result; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _insertTextNodeInEditableArea: function (range, text) { + // try to insert the text node in editable area + var textNode = this.document.createTextNode(text); + if (this.options.isEditableNode(range.sc) && $(range.sc).closest('[contenteditable]').attr('contenteditable') === 'true') { + if (dom.isText(range.sc) && this.isVisibleText(range.sc)) { + // If range is on visible text: split the text at offset and insert the text node + range.sc.splitText(range.so); + range.so = range.eo = dom.nodeLength(range.sc); + $(range.sc).after(textNode); + } else if (dom.isBR(range.sc)) { + if (range.sc.nextSibling && this.isVisibleText(range.sc.nextSibling)) { + $(range.sc).before(textNode); + } else { + $(range.sc).replaceWith(textNode); + } + } else if (dom.isVoid(range.sc)) { + $(range.sc).before(textNode); + } else if (range.sc.childNodes[range.so]) { + var node = range.sc.childNodes[range.so]; + if (dom.isBR(node)) { + $(node).replaceWith(textNode); + } else { + $(node).before(textNode); + } + } else if (dom.isText(range.sc)) { + $(range.sc).after(textNode); + } else if (this.options.isUnbreakableNode(range.sc)) { + $(range.sc).append(textNode); + } + } + if (!textNode.parentNode && this.options.isEditableNode(range.sc.parentNode)) { + $(range.sc).before(textNode); + } + + return textNode.parentNode && textNode; + }, + /** + * Perform operations that are necessary after the insertion of a visible character: + * adapt range for the presence of zero-width characters, move out of media, rerange. + * + * @private + */ + _removeInvisibleChar: function (range) { + if (range.sc.tagName || dom.ancestor(range.sc, dom.isAnchor)) { + return true; + } + var needReselect = false; + var fake = range.sc.parentNode; + if ((fake.className || '').indexOf('o_fake_editable') !== -1 && dom.isMedia(fake)) { + var $media = $(fake.parentNode); + $media[fake.previousElementSibling ? 'after' : 'before'](fake.firstChild); + needReselect = true; + } + if (range.sc.textContent.slice(range.so - 2, range.so - 1) === '\u200B') { + range.sc.textContent = range.sc.textContent.slice(0, range.so - 2) + range.sc.textContent.slice(range.so - 1); + range.so = range.eo = range.so - 1; + needReselect = true; + } + if (range.sc.textContent.slice(range.so, range.so + 1) === '\u200B') { + range.sc.textContent = range.sc.textContent.slice(0, range.so) + range.sc.textContent.slice(range.so + 1); + needReselect = true; + } + if (needReselect) { + range = range.normalize(); + } + return range; + }, + _standardizeRangeOnEdge: function (range) { + var self = this; + + var invisible = this.document.createTextNode('\u200B'); + + if (dom.isText(range.sc) && !this.isVisibleText(range.sc) && range.sc.nextSibling) { + var firstLeafOfNext = this.firstLeaf(range.sc.nextSibling); + range = this.context.invoke('editor.setRange', firstLeafOfNext, 0); + } + + // Create empty text node to have a range into the node + if (range.sc.tagName && !dom.isVoid(range.sc) && !range.sc.childNodes[range.so]) { + $(range.sc).append(invisible); + range = this.context.invoke('editor.setRange', invisible, 0); + } + + // On left edge of non-empty element: move before + var siblings = range.sc.parentNode && range.sc.parentNode.childNodes; + var isInEmptyElem = !siblings || !siblings.length || _.all(siblings, this.isBlankNode.bind(this)); + if ( + !range.so && !isInEmptyElem && + !(range.sc.previousSibling && range.sc.previousSibling.tagName === "BR") && + !this.options.isEditableNode(range.sc) + ) { + var point = range.getStartPoint(); + var newPoint = dom.prevPointUntil(point, function (pt) { + return pt.node !== range.sc && !pt.node.tagName && !self.isBlankText(pt.node); + }); + if (!newPoint || this.firstBlockAncestor(newPoint.node) !== this.firstBlockAncestor(point.node)) { + range = this.context.invoke('editor.setRange', point.node, point.offset); + } else { + range = this.context.invoke('editor.setRange', newPoint.node, newPoint.offset); + } + } + + return range; + }, + _wrapTextWithP: function (textNode) { + var self = this; + var isFormatNode = dom.ancestor(textNode, this.isFormatNode.bind(this)); + if (!isFormatNode) { + var hasInlineParent = dom.ancestor(textNode.parentNode, function (node) { + return !self.isNodeBlockType(node); + }); + if ( + !hasInlineParent && + (textNode.tagName || + !(textNode.previousSibling && this.isVisibleText(textNode.previousSibling) || + textNode.nextSibling && this.isVisibleText(textNode.nextSibling)) + ) + ) { + var blankP = this.document.createElement('p'); + $(textNode).after(blankP); + $(blankP).prepend(textNode); + } + } + }, +}); + +registry.add('HelperPlugin', HelperPlugin); + +return HelperPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/hint.js b/addons/web_editor/static/src/js/wysiwyg/plugin/hint.js new file mode 100644 index 00000000000..708ac89333f --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/hint.js @@ -0,0 +1,139 @@ +odoo.define('web_editor.wysiwyg.plugin.hint', function (require) { +'use strict'; + +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var dom = $.summernote.dom; + + +var HintPlugin = Plugins.hintPopover.extend({ + init: function (context) { + context.options.hint = (context.options.hint || []).concat(this._hints()); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public summernote module API + //-------------------------------------------------------------------------- + + /** + * Replace the current hint. + */ + replace: function () { + var self = this; + var $item = this.$content.find('.note-hint-item.active'); + if ($item.length) { + this.lastWordRange.select(); + this.context.invoke('HelperPlugin.deleteSelection'); + var range = this.context.invoke('editor.createRange'); + + this.nodeFromItem($item).each(function () { + $(range.sc).after(this); + range = self.context.invoke('editor.setRange', this, dom.nodeLength(this)); + }); + range.select(); + this.context.invoke('editor.saveRange'); + this.lastWordRange = null; + this.hide(); + this.context.triggerEvent('change', this.$editable.html(), this.$editable[0]); + this.context.invoke('editor.focus'); + } + }, + /** + * @param {JQueryEvent} e + */ + handleKeyup: function (e) { + var self = this; + if ([13, 38, 40].indexOf(e.keyCode) === -1) { // enter, up, down + var wordRange = this.context.invoke('editor.createRange'); + var keyword_1 = wordRange.sc.textContent.slice(0, wordRange.so); + if (this.hints.length && keyword_1) { + this.$content.empty(); + this.$popover.hide(); + this.lastWordRange = wordRange; + var hasMatch = false; + + // test all hints + this.hints.forEach(function (hint, idx) { + var match = keyword_1.match(hint.match); + if (match) { + hasMatch = true; + wordRange.so = wordRange.eo - match[0].length; + self.createGroup(idx, match[0]).appendTo(self.$content); + } + }); + if (!hasMatch) { + return; + } + + // select first .note-hint-item + this.$content.find('.note-hint-item:first').addClass('active'); + + // set position for popover after group is created + var rect = wordRange.getClientRects()[0]; + var bnd = { + top: rect.top + $(this.document).scrollTop(), + left: rect.left + $(this.document).scrollLeft(), + }; + this.$popover.css({ + left: bnd.left, + top: bnd.top + (this.direction === 'top' ? -this.$popover.outerHeight() : (rect.bottom - rect.top)) - 5, + }); + } else { + this.hide(); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get hint objects. + * + * @private + * @returns {Object[]} hints + */ + _hints: function () { + var self = this; + return [{ + className: 'o_hint_partner', + match: /\B@(\w+(\s\w*)?)$/, + search: function (keyword, callback) { + self._rpc({ + model: 'res.partner', + method: "search_read", + fields: ['id', 'name', 'email'], + domain: [ + ['name', 'ilike', keyword], + ], + limit: 10, + }).then(callback); + }, + template: function (partner) { + return partner.name + (partner.email ? ' <i style="color: #999;">(' + partner.email + ')</i>' : ''); + }, + content: function (item) { + return $(self.document.createTextNode('@' + item.name + '\u00A0')); + }, + }, + { + className: 'fa', + match: /:([\-+\w]+)$/, + search: function () {}, + template: function () { + return '<span class="fa fa-star">\u200B</span>'; + }, + content: function () {} + }, + ]; + }, +}); + +registry.add('hintPopover', null); + +return HintPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/history.js b/addons/web_editor/static/src/js/wysiwyg/plugin/history.js new file mode 100644 index 00000000000..a60ab41523b --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/history.js @@ -0,0 +1,81 @@ +odoo.define('web_editor.wysiwyg.plugin.history', function (require) { +'use strict'; + +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + + +var HistoryPlugin = Plugins.history.extend({ + /** + * Apply a snapshot. + * + * @override + */ + applySnapshot: function () { + this.context.invoke('MediaPlugin.hidePopovers'); + try { + this._super.apply(this, arguments); + } catch (e) { + console.error(e); + } + this.context.invoke('editor.focus'); + }, + /** + * Clear the history. + * + * @override + */ + clear: function () { + this.stack = []; + this.stackOffset = -1; + this.recordUndo(); + }, + /** + * Get the current history stack and the current offset. + * + * @returns {Object} {stack: Object, stackOffset: Integer} + */ + getHistoryStep: function () { + return { + stack: this.stack, + stackOffset: this.stackOffset, + }; + }, + /** + * Prevent errors with first snapshot. + * + * @override + */ + makeSnapshot: function () { + var rng = $.summernote.range.create(this.editable); + var snapshot = this._super(); + if (rng.sc === this.editable || $(rng.sc).has(this.editable).length) { + snapshot.bookmark.s.path = snapshot.bookmark.e.path = [0]; + snapshot.bookmark.s.offset = snapshot.bookmark.e.offset = 0; + } + return snapshot; + }, + /** + * @override + */ + recordUndo: function () { + if (!this.stack[this.stackOffset] || this.$editable.html() !== this.stack[this.stackOffset].contents) { + this._super(); + } + }, + /** + * @override + */ + undo: function () { + if (this.stackOffset > 0) { + this.stackOffset--; + } + this.applySnapshot(this.stack[this.stackOffset]); + }, +}); + +registry.add('HistoryPlugin', HistoryPlugin); + +return HistoryPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js new file mode 100644 index 00000000000..f06f8e91229 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js @@ -0,0 +1,1182 @@ +odoo.define('web_editor.wysiwyg.plugin.keyboard', function (require) { +'use strict'; + +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var dom = $.summernote.dom; +dom.isAnchor = function (node) { + return (node.tagName === 'A' || node.tagName === 'BUTTON' || $(node).hasClass('btn')) && + !$(node).hasClass('fa') && !$(node).hasClass('o_image'); +}; + +var KeyboardPlugin = AbstractPlugin.extend({ + events: { + 'summernote.keydown': '_onKeydown', + 'DOMNodeInserted .note-editable': '_removeGarbageSpans', + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Perform various DOM and range manipulations after a deletion: + * - Rerange out of BR elements + * - Clean the DOM at current range position + * + * @see _handleDeletion + * + * @private + * @param {Object} range + * @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret + * @returns {Object} range + */ + _afterDeletion: function (range, direction) { + range = direction === 'prev' ? this._insertInvisibleCharAfterSingleBR(range) : range; + range = this._rerangeOutOfBR(range, direction); + range = this._cleanRangeAfterDeletion(range); + return range; + }, + /** + * Perform operations that are necessary after the insertion of a visible character: + * - Adapt range for the presence of zero-width characters + * - Move out of media + * - Rerange + * + * @private + */ + _afterVisibleChar: function () { + var range = this.context.invoke('editor.createRange'); + if (range.sc.tagName || dom.ancestor(range.sc, dom.isAnchor)) { + return true; + } + var needReselect = false; + var fake = range.sc.parentNode; + if ((fake.className || '').indexOf('o_fake_editable') !== -1 && dom.isMedia(fake)) { + var $media = $(fake.parentNode); + $media[fake.previousElementSibling ? 'after' : 'before'](fake.firstChild); + needReselect = true; + } + if (range.sc.textContent.slice(range.so - 2, range.so - 1) === '\u200B') { + range.sc.textContent = range.sc.textContent.slice(0, range.so - 2) + range.sc.textContent.slice(range.so - 1); + range.so = range.eo = range.so - 1; + needReselect = true; + } + if (range.sc.textContent.slice(range.so, range.so + 1) === '\u200B') { + range.sc.textContent = range.sc.textContent.slice(0, range.so) + range.sc.textContent.slice(range.so + 1); + needReselect = true; + } + if (needReselect) { + range.normalize().select(); + } + }, + /** + * Perform various DOM and range manipulations to prepare a deletion: + * - Rerange within the element targeted by the range + * - Slice the text content if necessary + * - Move before an invisible BR if necessary + * - Replace a media with an empty SPAN if necessary + * - Change the direction of deletion if necessary + * - Clean the DOM at range position if necessary + * + * @see _handleDeletion + * + * @private + * @param {Object} range + * @param {String('prev'|'next')} direction + * @param {Boolean} didDeleteNodes true if nodes were already deleted prior to this call + * @returns {Object} {didDeleteNodes: Boolean, range: Object, direction: String('prev'|next')} + */ + _beforeDeletion: function (range, direction, didDeleteNodes) { + var res = { + range: range, + direction: direction, + didDeleteNodes: didDeleteNodes, + }; + + res.range = this._rerangeToOffsetChild(res.range, direction); + res.range = this._sliceAndRerangeBeforeDeletion(res.range); + res.range = direction === 'prev' ? this._moveBeforeInvisibleBR(res.range) : res.range; + + if (dom.isMedia(res.range.sc)) { + var span = this._replaceMediaWithEmptySpan(res.range.sc); + res.range = this.context.invoke('editor.setRange', span, 0); + res.didDeleteNodes = true; + return res; + } + + if (res.didDeleteNodes) { + res.direction = 'next'; + return res; + } + + res.range = this._cleanRangeBeforeDeletion(res.range, direction); + + return res; + }, + /** + * Clean the DOM at range position after a deletion: + * - Remove empty inline nodes + * - Fill the current node if it's empty + * + * @private + * @param {Object} range + * @returns {Object} range + */ + _cleanRangeAfterDeletion: function (range) { + var point = range.getStartPoint(); + + point = this.context.invoke('HelperPlugin.removeEmptyInlineNodes', point); + point = this.context.invoke('HelperPlugin.fillEmptyNode', point); + range = this.context.invoke('editor.setRange', point.node, point.offset); + return range; + }, + /** + * Clean the DOM at range position: + * - Remove all previous zero-width characters + * - Remove leading/trailing breakable space + * + * @private + * @param {Object} range + * @param {String('prev'|'next')} direction + * @returns {Object} range + */ + _cleanRangeBeforeDeletion: function (range, direction) { + if (direction === 'prev') { + this._removeAllPreviousInvisibleChars(range); + } + range = this._removeExtremeBreakableSpaceAndRerange(range); + return range; + }, + /** + * Get information on the range in order to perform a deletion: + * - The point at which to delete, if any + * - Whether the node contains a block + * - The block to remove, if any + * + * @private + * @param {Object} range + * @param {String('prev'|'next')} direction + * @param {Boolean} wasOnStartOfBR true if the requested deletion started at + * the beginning of a BR element + * @returns {Object} { + * point: {false|Object}, + * hasBlock: {Boolean}, + * blockToRemove: {false|Node}, + * } + */ + _getDeleteInfo: function (range, direction, wasOnStartOfBR) { + var self = this; + var hasBlock = false; + var blockToRemove = false; + var method = direction === 'prev' ? 'prevPointUntil' : 'nextPointUntil'; + + var pt = range.getStartPoint(); + pt = dom[method](pt, function (point) { + var isAtStartOfMedia = !point.offset && dom.isMedia(point.node); + var isBRorHR = point.node.tagName === 'BR' || point.node.tagName === 'HR'; + var isRootBR = wasOnStartOfBR && point.node === range.sc; + var isOnRange = range.ec === point.node && range.eo === point.offset; + + if (!point.offset && self.context.invoke('HelperPlugin.isNodeBlockType', point.node)) { + hasBlock = true; + if (blockToRemove) { + return true; + } + } + + if (!blockToRemove && (isAtStartOfMedia || isBRorHR && !isRootBR)) { + blockToRemove = point.node; + return false; + } + + if (isOnRange) { + return false; + } + + return self._isDeletableNode(point.node); + }); + + return { + point: pt || false, + hasBlock: hasBlock, + blockToRemove: blockToRemove, + }; + }, + /** + * Handle deletion (BACKSPACE / DELETE). + * + * @private + * @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret + * @returns {Boolean} true if case handled + */ + _handleDeletion: function (direction) { + var didDeleteNodes = this.context.invoke('HelperPlugin.deleteSelection') + var range = this.context.invoke('editor.createRange'); + var wasOnStartOfBR = direction === 'prev' && !range.so && range.sc.tagName === 'BR'; + + var temp = this._beforeDeletion(range, direction, didDeleteNodes); + didDeleteNodes = temp.didDeleteNodes; + range = temp.range; + direction = temp.direction; + + if (!didDeleteNodes) { + var newRange = this._performDeletion(range, direction, wasOnStartOfBR); + didDeleteNodes = newRange.so !== range.so || newRange.sc !== range.sc; + range = newRange; + } + + range = this._afterDeletion(range, direction); + + range = range.collapse(direction === 'prev').select(); + this.editable.normalize(); + return didDeleteNodes; + }, + /** + * Handle ENTER. + * + * @private + * @returns {Boolean} true if case handled + */ + _handleEnter: function () { + var self = this; + var range = this.context.invoke('editor.createRange'); + + var ancestor = dom.ancestor(range.sc, function (node) { + return dom.isLi(node) || self.options.isUnbreakableNode(node.parentNode) && node.parentNode !== self.editable || + self.context.invoke('HelperPlugin.isNodeBlockType', node) && !dom.ancestor(node, dom.isLi); + }); + + if ( + dom.isLi(ancestor) && !$(ancestor.parentNode).hasClass('list-group') && + this.context.invoke('HelperPlugin.getRegexBlank', { + space: true, + newline: true, + }).test(ancestor.textContent) && + $(ancestor).find('br').length <= 1 && + !$(ancestor).find('.fa, img').length + ) { + // double enter in a list make oudent + this.context.invoke('BulletPlugin.outdent'); + return true; + } + + var btn = dom.ancestor(range.sc, function (n) { + return $(n).hasClass('btn'); + }); + + var point = range.getStartPoint(); + + if (!point.node.tagName && this.options.isUnbreakableNode(point.node.parentNode)) { + return this._handleShiftEnter(); + } + + if (point.node.tagName && point.node.childNodes[point.offset] && point.node.childNodes[point.offset].tagName === "BR") { + point = dom.nextPoint(point); + } + if (point.node.tagName === "BR") { + point = dom.nextPoint(point); + } + + var next = this.context.invoke('HelperPlugin.splitTree', ancestor, point, { + isSkipPaddingBlankHTML: !this.context.invoke('HelperPlugin.isNodeBlockType', point.node.parentNode) && !!point.node.parentNode.nextSibling + }); + while (next.firstChild) { + next = next.firstChild; + } + + // if there is no block in the split parents, then we add a br between the two node + var hasSplitBlock = false; + var node = next; + var lastChecked = node; + while (node && node !== ancestor && node !== this.editable) { + if (this.context.invoke('HelperPlugin.isNodeBlockType', node)) { + hasSplitBlock = true; + break; + } + lastChecked = node; + node = node.parentNode; + } + if (!hasSplitBlock && lastChecked.tagName) { + $(lastChecked).before(this.document.createElement('br')); + } + + if (!next.tagName) { + this.context.invoke('HelperPlugin.secureExtremeSingleSpace', next); + } + if (next.tagName !== "BR" && next.innerHTML === "") { + next.innerHTML = '\u200B'; + } + if (ancestor) { + var firstChild = this.context.invoke('HelperPlugin.firstLeaf', ancestor); + var lastChild = this.context.invoke('HelperPlugin.lastLeaf', ancestor); + if (this.context.invoke('HelperPlugin.isBlankNode', ancestor)) { + firstChild = dom.isText(firstChild) ? firstChild.parentNode : firstChild; + $(firstChild).contents().remove(); + $(firstChild).append(this.document.createElement('br')); + } + if (lastChild.tagName === 'BR' && lastChild.previousSibling) { + $(lastChild).after(this.document.createTextNode('\u200B')); + } + } + + // move to next editable area + point = this.context.invoke('HelperPlugin.makePoint', next, 0); + if ( + (point.node.tagName && point.node.tagName !== 'BR') || + !this.context.invoke('HelperPlugin.isVisibleText', point.node.textContent) + ) { + point = dom.nextPointUntil(point, function (pt) { + if (pt.node === point.node) { + return; + } + return ( + pt.node.tagName === "BR" || + self.context.invoke('HelperPlugin.isVisibleText', pt.node) + ) && + self.options.isEditableNode(pt.node); + }); + point = point || this.context.invoke('HelperPlugin.makePoint', next, 0); + if (point.node.tagName === "BR") { + point = dom.nextPoint(point); + } + } + + if (!hasSplitBlock && !point.node.tagName) { + point.node.textContent = '\u200B' + point.node.textContent; + point.offset = 1; + } + + // if the left part of the split node ends with a space, replace that space with nbsp + if (range.sc.textContent) { + var endSpace = this.context.invoke('HelperPlugin.getRegex', 'endSpace'); + range.sc.textContent = range.sc.textContent.replace(endSpace, + function (trailingSpaces) { + return Array(trailingSpaces.length + 1).join('\u00A0'); + } + ); + } + + // On buttons, we want to split the button and move to the beginning of it + if (btn) { + next = dom.ancestor(point.node, function (n) { + return $(n).hasClass('btn'); + }); + + // Move carret to the new button + range = this.context.invoke('editor.setRange', next.firstChild, 0); + range.select(); + + // Force content in empty buttons, the carret can be moved there + this.context.invoke('LinkPopover.hide'); + this.context.invoke('LinkPopover.fillEmptyLink', next, true); + this.context.invoke('LinkPopover.fillEmptyLink', btn, true); + } else { + range = this.context.invoke('editor.setRange', point.node, point.offset); + range.normalize().select(); + } + + return true; + }, + /** + * Handle SHIFT+ENTER. + * + * @private + * @returns {Boolean} true if case handled + */ + _handleShiftEnter: function () { + var range = this.context.invoke('editor.createRange'); + var target = range.sc.childNodes[range.so] || range.sc; + var before; + if (target.tagName) { + if (target.tagName === "BR") { + before = target; + } else if (target === range.sc) { + if (range.so) { + before = range.sc.childNodes[range.so - 1]; + } else { + before = this.document.createTextNode(''); + $(range.sc).append(before); + } + } + } else { + before = target; + var after = target.splitText(target === range.sc ? range.so : 0); + if ( + !after.nextSibling && after.textContent === '' && + this.context.invoke('HelperPlugin.isNodeBlockType', after.parentNode) + ) { + after.textContent = '\u200B'; + } + if (!after.tagName && (!after.previousSibling || after.previousSibling.tagName === "BR")) { + after.textContent = after.textContent.replace(startSpace, '\u00A0'); + } + } + + if (!before) { + return true; + } + + var br = this.document.createElement('br'); + $(before).after(br); + var next = this.context.invoke('HelperPlugin.makePoint', br, 0); + var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace'); + + if (!before.tagName) { + next = dom.nextPoint(next); + var nextNode = this.context.invoke('HelperPlugin.firstLeaf', next.node.childNodes[next.offset] || next.node); + if (!nextNode.tagName) { + next.node = nextNode; + next.offset = 0; + } + } + + if ( + next.node.tagName === "BR" && next.node.nextSibling && + !next.node.nextSibling.tagName && !dom.ancestor(next.node, dom.isPre) + ) { + next.node.nextSibling.textContent = next.node.nextSibling.textContent.replace(startSpace, '\u00A0'); + } + if ( + !next.node.tagName && + (!next.node.previousSibling || next.node.previousSibling.tagName === "BR") && + !dom.ancestor(next.node, dom.isPre) + ) { + next.node.textContent = next.node.textContent.replace(startSpace, '\u00A0'); + } + + range = this.context.invoke('editor.setRange', next.node, next.offset); + range.select(); + + return true; + }, + /** + * Insert a zero-width character after a BR if the range is + * at the beginning of an invisible text node + * and after said single BR element. + * + * @private + * @param {Object} range + * @returns {Object} range + */ + _insertInvisibleCharAfterSingleBR: function (range) { + if (this._isAtStartOfInvisibleText(range) && this._isAfterSingleBR(range.sc)) { + var invisibleChar = this.document.createTextNode('\u200B'); + $(range.sc.previousSibling).after(invisibleChar); + range = this.context.invoke('editor.setRange', invisibleChar, 1); + } + return range; + }, + /** + * Return true if the node comes after a BR element. + * + * @private + * @param {Node} node + * @returns {Boolean} + */ + _isAfterBR: function (node) { + return node.previousSibling && node.previousSibling.tagName === 'BR'; + }, + /** + * Return true if the range if positioned after a BR element that doesn't visually + * show a new line in the DOM: a BR in an element that has only a BR, or text then a BR. + * eg: <p><br></p> or <p>text<br></p> + * + * @private + * @param {Object} range + * @returns {Boolean} + */ + _isAfterInvisibleBR: function (range) { + return this._isAfterOnlyBR(range) || this._isAfterOnlyTextThenBR(range); + }, + /** + * Return true if the range is positioned on a text node, after an zero-width character. + * + * @private + * @param {Object} range + * @returns {Boolean} + */ + _isAfterInvisibleChar: function (range) { + return !range.sc.tagName && range.so && range.sc.textContent[range.so - 1] === '\u200B'; + }, + /** + * Return true if the range is positioned on a text node, after an leading zero-width character. + * + * @private + * @param {Object} range + * @returns {Boolean} + */ + _isAfterLeadingInvisibleChar: function (range) { + return !range.sc.tagName && range.so === 1 && range.sc.textContent[0] === '\u200B'; + }, + /** + * Return true if the range if positioned after a BR element in a node that has only a BR. + * eg: <p><br></p> + * + * @private + * @param {Object} range + * @returns {Boolean} + */ + _isAfterOnlyBR: function (range) { + return this._hasOnlyBR(range.sc) && range.so === 1; + }, + /** + * Return true if the node has for only element child a BR element. + * + * @private + * @param {Node} node + * @returns {Boolean} + */ + _hasOnlyBR: function (node) { + return node.childElementCount === 1 && node.firstChild.tagName === 'BR'; + }, + /** + * Return true if the range if positioned after a BR element in a node that has only text + * and ends with a BR. + * eg: <p>text<br></p> + * + * @private + * @param {Object} range + * @returns {Boolean} + */ + _isAfterOnlyTextThenBR: function (range) { + var hasTrailingBR = range.sc.lastChild && range.sc.lastChild.tagName === 'BR'; + if (!hasTrailingBR) { + return false; + } + var hasOnlyTextThenBR = _.all(range.sc.childNodes, function (n) { + return dom.isText(n) || n === range.sc.lastChild; + }); + var isAfterTrailingBR = range.so === dom.nodeLength(range.sc); + return hasOnlyTextThenBR && isAfterTrailingBR; + }, + /** + * Return true if the node is after a single BR. + * + * @private + * @param {Node} node + * @returns {Boolean} + */ + _isAfterSingleBR: function (node) { + var isPreviousAfterBR = node.previousSibling && this._isAfterBR(node.previousSibling); + return this._isAfterBR(node) && !isPreviousAfterBR; + }, + /** + * Return true if the node comes after two BR elements. + * + * @private + * @param {Node} node + * @returns {Boolean} + */ + _isAfterTwoBRs: function (node) { + var isAfterBR = this._isAfterBR(node); + var isPreviousSiblingAfterBR = node.previousSibling && this._isAfterBR(node.previousSibling); + return isAfterBR && isPreviousSiblingAfterBR; + }, + /** + * Return true if the range is positioned at the start of an invisible text node. + * + * @private + * @param {Object} range + * @returns {Boolean} + */ + _isAtStartOfInvisibleText: function (range) { + return !range.so && dom.isText(range.sc) && !this.context.invoke('HelperPlugin.isVisibleText', range.sc); + }, + /** + * Return true if the range is positioned on a text node, before a trailing zero-width character. + * + * @private + * @param {Object} range + * @returns {Boolean} + */ + _isBeforeTrailingInvisibleChar: function (range) { + var isBeforeLastCharOfText = !range.sc.tagName && range.so === dom.nodeLength(range.sc) - 1; + var isLastCharInvisible = range.sc.textContent.slice(range.so) === '\u200B'; + return isBeforeLastCharOfText && isLastCharInvisible; + }, + /** + * Return true if the node is deletable. + * + * @private + * @param {Node} node + * @return {Boolean} + */ + _isDeletableNode: function (node) { + var isVisibleText = this.context.invoke('HelperPlugin.isVisibleText', node); + var isMedia = dom.isMedia(node); + var isBR = node.tagName === 'BR'; + var isEditable = this.options.isEditableNode(node); + return isEditable && (isVisibleText || isMedia || isBR); + }, + /** + * Return true if the range is positioned on an edge to delete, depending on the given direction. + * + * @private + * @param {Object} range + * @param {String('prev'|'next')} direction + */ + _isOnEdgeToDelete: function (range, direction) { + var isOnBR = range.sc.tagName === 'BR'; + var parentHasOnlyBR = range.sc.parentNode && range.sc.parentNode.innerHTML.trim() === "<br>"; + var isOnDirEdge; + if (direction === 'next') { + isOnDirEdge = range.so === dom.nodeLength(range.sc); + } else { + isOnDirEdge = range.so === 0; + } + return (!isOnBR || parentHasOnlyBR) && isOnDirEdge; + }, + /** + * Move the range before a BR if that BR doesn't visually show a new line in the DOM. + * Return the new range. + * + * @private + * @param {Object} range + * @returns {Object} range + */ + _moveBeforeInvisibleBR: function (range) { + if (this._isAfterInvisibleBR(range)) { + range.so -= 1; + } + return range; + }, + /** + * Perform a deletion in the given direction. + * Note: This is where the actual deletion takes place. + * It should be preceded by _beforeDeletion and + * followed by _afterDeletion. + * + * @see _handleDeletion + * + * @private + * @param {Object} range + * @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret + * @param {Boolean} wasOnStartOfBR true if the requested deletion started at + * the beginning of a BR element + * @returns {Object} range + */ + _performDeletion: function (range, direction, wasOnStartOfBR) { + var didDeleteNodes = false; + if (this._isOnEdgeToDelete(range, direction)) { + var rest = this.context.invoke('HelperPlugin.deleteEdge', range.sc, direction); + didDeleteNodes = !!rest; + if (didDeleteNodes) { + range = this.context.invoke('editor.setRange', rest.node, rest.offset); + return range; + } + } + + var deleteInfo = this._getDeleteInfo(range, direction, wasOnStartOfBR); + + if (!deleteInfo.point) { + return range; + } + + var point = deleteInfo.point; + var blockToRemove = deleteInfo.blockToRemove; + var hasBlock = deleteInfo.hasBlock; + + var isLonelyBR = blockToRemove && blockToRemove.tagName === 'BR' && this._hasOnlyBR(blockToRemove.parentNode); + var isHR = blockToRemove && blockToRemove.tagName === "HR"; + + if (blockToRemove && !isLonelyBR) { + $(blockToRemove).remove(); + point = isHR ? this.context.invoke('HelperPlugin.deleteEdge', range.sc, direction) : point; + didDeleteNodes = true; + } else if (!hasBlock) { + var isAtEndOfNode = point.offset === dom.nodeLength(point.node); + var shouldMove = isAtEndOfNode || direction === 'next' && point.offset; + + point.offset = shouldMove ? point.offset - 1 : point.offset; + point.node = this._removeCharAtOffset(point); + didDeleteNodes = true; + + var isInPre = !!dom.ancestor(range.sc, dom.isPre); + if (!isInPre) { + this.context.invoke('HelperPlugin.secureExtremeSingleSpace', point.node); + } + + if (direction === 'prev' && !point.offset && !this._isAfterBR(point.node)) { + point.node = this._replaceLeadingSpaceWithSingleNBSP(point.node); + } + } + + if (didDeleteNodes) { + range = this.context.invoke('editor.setRange', point.node, point.offset); + } + return range; + }, + /** + * Prevent the appearance of a text node with the editable DIV as direct parent: + * wrap it in a p element. + * + * @private + */ + _preventTextInEditableDiv: function () { + var range = this.context.invoke('editor.createRange'); + while ( + dom.isText(this.editable.firstChild) && + !this.context.invoke('HelperPlugin.isVisibleText', this.editable.firstChild) + ) { + var node = this.editable.firstChild; + if (node && node.parentNode) { + node.parentNode.removeChild(node); + } + } + var editableIsEmpty = !this.editable.childNodes.length; + if (editableIsEmpty) { + var p = this.document.createElement('p'); + p.innerHTML = '<br>'; + this.editable.appendChild(p); + range = this.context.invoke('editor.setRange', p, 0); + } else if (this.context.invoke('HelperPlugin.isBlankNode', this.editable.firstChild) && !range.sc.parentNode) { + this.editable.firstChild.innerHTML = '<br/>'; + range = this.context.invoke('editor.setRange', this.editable.firstChild, 0); + } + + range.select(); + }, + /** + * Remove all invisible chars before the current range, that are adjacent to it, + * then rerange. + * + * @private + * @param {Object} range + * @returns {Object} range + */ + _removeAllPreviousInvisibleChars: function (range) { + while (this._isAfterInvisibleChar(range)) { + var text = range.sc.textContent; + range.sc.textContent = text.slice(0, range.so - 1) + text.slice(range.so, text.length); + range.so -= 1; + } + return range; + }, + /** + * Remove a char from a point's text node, at the point's offset. + * + * @private + * @param {Object} point + * @returns {Node} + */ + _removeCharAtOffset: function (point) { + var text = point.node.textContent; + var startToOffset = text.slice(0, point.offset); + var offsetToEnd = text.slice(point.offset + 1); + point.node.textContent = startToOffset + offsetToEnd; + return point.node; + }, + /** + * Remove any amount of leading/trailing breakable space at range position. + * Then move the range and return it. + * + * @private + * @param {Object} range + * @returns {Object} range + */ + _removeExtremeBreakableSpaceAndRerange: function (range) { + var isInPre = !!dom.ancestor(range.sc, dom.isPre); + if (!range.sc.tagName && !isInPre) { + var changed = this.context.invoke('HelperPlugin.removeExtremeBreakableSpace', range.sc); + range.so = range.eo = range.so > changed.start ? range.so - changed.start : 0; + range.so = range.eo = range.so > dom.nodeLength(range.sc) ? dom.nodeLength(range.sc) : range.so; + range.select(); + this.context.invoke('editor.saveRange'); + } + return range; + }, + /** + * Patch for Google Chrome's contenteditable SPAN bug. + * + * @private + * @param {jQueryEvent} e + */ + _removeGarbageSpans: function (e) { + if (e.target.className === "" && e.target.tagName == "SPAN" && + e.target.style.fontStyle === "inherit" && + e.target.style.fontVariantLigatures === "inherit" && + e.target.style.fontVariantCaps === "inherit") { + var $span = $(e.target); + $span.after($span.contents()).remove(); + } + }, + /** + * Replace all leading space from a text node with one non-breakable space. + * + * @param {Node} node + * @returns {Node} node + */ + _replaceLeadingSpaceWithSingleNBSP: function (node) { + var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace'); + node.textContent = node.textContent.replace(startSpace, '\u00A0'); + return node; + }, + /** + * Replace a media node with an empty SPAN and return that SPAN. + * + * @param {Node} media + * @returns {Node} span + */ + _replaceMediaWithEmptySpan: function (media) { + var span = this.document.createElement('span'); + media = dom.ancestor(media, function (n) { + return !n.parentNode || !dom.isMedia(n.parentNode); + }); + $(media).replaceWith(span); + return span; + }, + /** + * Move the (collapsed) range to get out of BR elements. + * + * @private + * @param {Object} range + * @returns {Object} range + */ + _rerangeOutOfBR: function (range, direction) { + range = this._rerangeToFirstNonBRElementLeaf(range); + range = this._rerangeToNextNonBR(range, direction === 'next'); + return range; + }, + /** + * Move the (collapsed) range to the first leaf that is not a BR element. + * + * @private + * @param {Object} range + * @returns {Object} range + */ + _rerangeToFirstNonBRElementLeaf: function (range) { + var leaf = this.context.invoke('HelperPlugin.firstNonBRElementLeaf', range.sc); + if (leaf !== range.sc) { + range = this.context.invoke('editor.setRange', leaf, 0); + } + return range; + }, + /** + * Move the (collapsed) range to the next (or previous) node that is not a BR element. + * + * @private + * @param {Object} range + * @param {Boolean} previous true to move to the previous node + * @returns {Object} range + */ + _rerangeToNextNonBR: function (range, previous) { + var point = range.getStartPoint(); + var method = previous ? 'prevPointUntil' : 'nextPointUntil'; + point = dom[method](point, function (pt) { + return pt.node.tagName !== 'BR'; + }); + range = this.context.invoke('editor.setRange', point.node, point.offset); + return range; + }, + /** + * Move the (collapsed) range to the child of the node at the current offset if possible. + * + * @private + * @param {Object} range + * @param {String('prev'|'next')} direction + * @returns {Object} range + */ + _rerangeToOffsetChild: function (range, direction) { + if (range.sc.childNodes[range.so]) { + var node; + var offset; + if (direction === 'prev' && range.so > 0) { + node = range.sc.childNodes[range.so - 1]; + offset = dom.nodeLength(node); + range = this.context.invoke('editor.setRange', node, offset); + } else { + node = range.sc.childNodes[range.so]; + offset = 0; + range = this.context.invoke('editor.setRange', node, offset); + } + } + return range; + }, + /** + * Before a deletion, if necessary, slice the text content at range, then rerange. + * + * @param {Object} range + * @returns {Object} range + */ + _sliceAndRerangeBeforeDeletion: function (range) { + if (this._isAfterLeadingInvisibleChar(range) && !this._isAfterTwoBRs(range.sc)) { + range.sc.textContent = range.sc.textContent.slice(1); + range.so = 0; + } + if (this._isBeforeTrailingInvisibleChar(range) && !this._isAfterBR(range.sc)) { + range.sc.textContent = range.sc.textContent.slice(0, range.so); + } + return range; + }, + + + //-------------------------------------------------------------------------- + // Handle + //-------------------------------------------------------------------------- + + /** + * Customize handling of certain keydown events. + * + * @private + * @param {SummernoteEvent} se + * @param {jQueryEvent} e + * @returns {Boolean} true if case handled + */ + _onKeydown: function (se, e) { + var self = this; + var handled = false; + + if (e.key && + (e.key.length === 1 || e.key === "Dead" || e.key === "Unidentified") && + !e.ctrlKey && !e.altKey && !e.metaKey) { + + if (e.key === "Dead" || e.key === "Unidentified") { + this._accented = true; + } + + // Record undo only if either: + clearTimeout(this.lastCharIsVisibleTime); + // e.key is punctuation or space + var stopChars = [' ', ',', ';', ':', '?', '.', '!']; + if (stopChars.indexOf(e.key) !== -1) { + this.lastCharVisible = false; + } + // or not on top of history stack (record undo after undo) + var history = this.context.invoke('HistoryPlugin.getHistoryStep'); + if (history && history.stack.length && history.stackOffset < history.stack.length - 1) { + this.lastCharVisible = false; + } + // or no new char for 500ms + this.lastCharIsVisibleTime = setTimeout(function () { + self.lastCharIsVisible = false; + }, 500); + if (!this.lastCharIsVisible) { + this.lastCharIsVisible = true; + this.context.invoke('HistoryPlugin.recordUndo'); + } + + if (e.key !== "Dead") { + this._onVisibleChar(e, this._accented); + } + } else { + this.lastCharIsVisible = false; + this.context.invoke('editor.clearTarget'); + this.context.invoke('MediaPlugin.hidePopovers'); + this.context.invoke('editor.beforeCommand'); + switch (e.keyCode) { + case 8: // BACKSPACE + handled = this._onBackspace(e); + break; + case 9: // TAB + handled = this._onTab(e); + break; + case 13: // ENTER + handled = this._onEnter(e); + break; + case 46: // DELETE + handled = this._onDelete(e); + break; + } + if (handled) { + this._preventTextInEditableDiv(); + this.context.invoke('editor.saveRange'); + e.preventDefault(); + this.context.invoke('editor.afterCommand'); + } + } + if (e.key !== "Dead") { + this._accented = false; + } + }, + /** + * Handle BACKSPACE keydown event. + * + * @private + * @param {jQueryEvent} e + * @returns {Boolean} true if case is handled and event default must be prevented + */ + _onBackspace: function (e) { + var range = this.context.invoke('editor.createRange'); + var needOutdent = false; + + // Special cases + if (range.isCollapsed()) { + + // Do nothing if on left edge of a table cell + var point = range.getStartPoint(); + if (point.node.childNodes[point.offset]) { + point.node = point.node.childNodes[point.offset]; + point.offset = dom.nodeLength(point.node); + } + if (this.context.invoke('HelperPlugin.isLeftEdgeOfTag', point, 'TD')) { + return true; + } + + // Outdent if on left edge of an indented block + point = range.getStartPoint(); + var isIndented = !!dom.ancestor(point.node, function (n) { + var style = dom.isCell(n) ? 'paddingLeft' : 'marginLeft'; + return n.tagName && !!parseFloat(n.style[style] || 0); + }); + if (this.context.invoke('HelperPlugin.isLeftEdgeOfBlock', point)) { + if (isIndented) { + this.context.invoke('BulletPlugin.outdent'); + return true; + } + if (dom.ancestor(range.sc, dom.isLi)) { + needOutdent = true; + } + } + } + + var flag = this._handleDeletion('prev'); + + if (!flag && needOutdent) { + range.select(); + this.context.invoke('BulletPlugin.outdent'); + } + + return true; + }, + /** + * Handle DELETE keydown event. + * + * @private + * @param {jQueryEvent} e + * @returns {Boolean} true if case is handled and event default must be prevented + */ + _onDelete: function (e) { + var range = this.context.invoke('editor.createRange'); + + // Special case + if (range.isCollapsed()) { + // Do nothing if on left edge of a table cell + if (this.context.invoke('HelperPlugin.isRightEdgeOfTag', range.getStartPoint(), 'TD')) { + return true; + } + } + + this._handleDeletion('next'); + return true; + }, + /** + * Handle ENTER keydown event. + * + * @private + * @param {jQueryEvent} e + * @returns {Boolean} true if case is handled and event default must be prevented + */ + _onEnter: function (e) { + this.context.invoke('HelperPlugin.deleteSelection'); + if (e.shiftKey) { + this._handleShiftEnter(); + } else if (e.ctrlKey) { + this.context.invoke('TextPlugin.insertHR'); + } else { + this._handleEnter(); + } + return true; + }, + /** + * Handle TAB keydown event. + * + * @private + * @param {jQueryEvent} e + * @returns {Boolean} true if case is handled and event default must be prevented + */ + _onTab: function (e) { + // If TAB not handled, prevent default and do nothing + if (!this.options.keyMap.pc.TAB) { + this.trigger_up('wysiwyg_blur', { + key: 'TAB', + keyCode: 9, + shiftKey: e.shiftKey, + }); + return true; + } + var range = this.context.invoke('editor.createRange'); + var point = range.getStartPoint(); + var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace'); + + if (!range.isOnCell()) { + // If on left edge point: indent/outdent + if (!point.node.tagName) { // Clean up start spaces on textNode + point.node.textContent.replace(startSpace, function (startSpaces) { + point.offset = startSpaces.length === point.offset ? 0 : point.offset; + return ''; + }); + } + if (this.context.invoke('HelperPlugin.isLeftEdgeOfBlock', point) || dom.isEmpty(point.node)) { + if (e.shiftKey) { + this.context.invoke('BulletPlugin.outdent'); + } else { + this.context.invoke('BulletPlugin.indent'); + } + this.context.invoke('HelperPlugin.normalize'); + return true; + } + // Otherwise insert a tab or do nothing + if (!e.shiftKey) { + this.context.invoke('TextPlugin.insertTab'); + this.context.invoke('HelperPlugin.normalize'); + } + return true; + } + // In table, on tab switch to next cell + return false; + }, + /** + * Handle visible char keydown event. + * + * @private + * @param {jQueryEvent} e + * @returns {Boolean} true if case is handled and event default must be prevented + */ + _onVisibleChar: function (e, accented) { + var self = this; + e.preventDefault(); + if (accented) { + this.editable.normalize(); + var baseRange = this.context.invoke('editor.createRange'); + + var $parent = $(baseRange.sc.parentNode); + var parentContenteditable = $parent.attr('contenteditable'); + $parent.attr('contenteditable', false); + + var accentPlaceholder = this.document.createElement('span'); + $(baseRange.sc).after(accentPlaceholder); + $(accentPlaceholder).attr('contenteditable', true); + + var range = this.context.invoke('editor.setRange', accentPlaceholder, 0); + range.select(); + + setTimeout(function () { + var accentedChar = accentPlaceholder.innerHTML; + $(accentPlaceholder).remove(); + if (parentContenteditable) { + $parent.attr('contenteditable', parentContenteditable); + } else { + $parent.removeAttr('contenteditable'); + } + baseRange.select(); + self.context.invoke('HelperPlugin.insertTextInline', accentedChar); + }); + } else { + this.context.invoke('HelperPlugin.insertTextInline', e.key); + } + return true; + }, +}); + +registry.add('KeyboardPlugin', KeyboardPlugin); + +return KeyboardPlugin; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/link.js b/addons/web_editor/static/src/js/wysiwyg/plugin/link.js new file mode 100644 index 00000000000..e04f3e9d340 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/link.js @@ -0,0 +1,479 @@ +odoo.define('web_editor.wysiwyg.plugin.link', function (require) { +'use strict'; + +var core = require('web.core'); +var LinkDialog = require('wysiwyg.widgets.LinkDialog'); +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var _t = core._t; +var dom = $.summernote.dom; +dom.isAnchor = function (node) { + return (node.tagName === 'A' || node.tagName === 'BUTTON' || $(node).hasClass('btn')) && + !$(node).hasClass('fa') && !$(node).hasClass('o_image'); +}; + +//-------------------------------------------------------------------------- +// link +//-------------------------------------------------------------------------- + +var LinkPlugin = Plugins.linkDialog.extend({ + events: { + 'dblclick .note-editable': '_onDblclick', + }, + + //-------------------------------------------------------------------------- + // Public summernote module API + //-------------------------------------------------------------------------- + + /** + * @override + * @param {Object} linkInfo + * @returns {Promise} + */ + showLinkDialog: function (linkInfo) { + var self = this; + this.context.invoke('editor.hidePopover'); + var media = this.context.invoke('editor.restoreTarget'); + + if (linkInfo.range) { + var r = linkInfo.range.getPoints(); + + // move carret in icon, video... + if (!media && (dom.ancestor(r.sc, dom.isImg) || dom.ancestor(r.sc, dom.isIcon))) { + media = dom.ancestor(r.sc, dom.isImg) || dom.ancestor(r.sc, dom.isIcon); + } + // if select a text content in anchor then click on image then click on anchor button + if (dom.isImg(media) || dom.isIcon(media)) { + r.sc = media.parentNode; + r.so = [].indexOf.call(media.parentNode.childNodes, media); + r.ec = media; + r.eo = dom.nodeLength(media); + + linkInfo.range.sc = r.sc; + linkInfo.range.so = r.so; + linkInfo.range.ec = r.ec; + linkInfo.range.eo = r.eo; + } + + linkInfo.isAnchor = linkInfo.range.isOnAnchor(); + linkInfo.className = linkInfo.isAnchor ? dom.ancestor(r.sc, dom.isAnchor).className : ''; + linkInfo.url = linkInfo.isAnchor ? ($(dom.ancestor(r.sc, dom.isAnchor)).attr('href') || '').replace(window.location.origin, '') : ''; + + var nodes = []; + if (linkInfo.isAnchor) { + nodes = dom.ancestor(r.sc, dom.isAnchor).childNodes; + } else if (!linkInfo.range.isCollapsed()) { + if (dom.isImg(media) || dom.isIcon(media)) { + nodes.push(media); + } else { + if (r.sc.tagName) { + r.sc = (r.so ? r.sc.childNodes[r.so] : r.sc).firstChild || r.sc; + r.so = 0; + } else if (r.so !== r.sc.textContent.length) { + if (r.sc === r.ec) { + r.ec = r.sc = r.sc.splitText(r.so); + r.eo -= r.so; + } else { + r.sc = r.sc.splitText(r.so); + } + r.so = 0; + } + if (r.ec.tagName) { + r.ec = (r.eo ? r.ec.childNodes[r.eo - 1] : r.ec).lastChild || r.ec; + r.eo = r.ec.textContent.length; + } else if (r.eo !== r.ec.textContent.length) { + r.ec.splitText(r.eo); + } + + // browsers can't target a picture or void node + if (dom.isVoid(r.sc) || dom.isImg(r.sc)) { + r.so = dom.listPrev(r.sc).length - 1; + r.sc = r.sc.parentNode; + } + if (dom.isBR(r.ec)) { + r.eo = dom.listPrev(r.ec).length - 1; + r.ec = r.ec.parentNode; + } else if (dom.isVoid(r.ec) || dom.isImg(r.sc)) { + r.eo = dom.listPrev(r.ec).length; + r.ec = r.ec.parentNode; + } + linkInfo.range.sc = r.sc; + linkInfo.range.so = r.so; + linkInfo.range.ec = r.ec; + linkInfo.range.eo = r.eo; + linkInfo.range.select(); + this.context.invoke('editor.saveRange'); + linkInfo.range = this.context.invoke('editor.createRange'); + + // search nodes to insert in the anchor + + var startPoint = this.context.invoke('HelperPlugin.makePoint', r.sc, r.so); + var endPoint = this.context.invoke('HelperPlugin.makePoint', r.ec, r.eo); + dom.walkPoint(startPoint, endPoint, function (point) { + var node = point.node.childNodes && point.node.childNodes[point.offset] || point.node; + nodes.push(node); + }); + + nodes = _.filter(_.uniq(nodes), function (node) { + return nodes.indexOf(node.parentNode) === -1; + }); + } + } + + if (nodes.length > 0) { + var text = ""; + linkInfo.images = []; + for (var i = 0; i < nodes.length; i++) { + if (dom.ancestor(nodes[i], dom.isImg)) { + text += dom.ancestor(nodes[i], dom.isImg).outerHTML; + } else if (dom.ancestor(nodes[i], dom.isIcon)) { + text += dom.ancestor(nodes[i], dom.isIcon).outerHTML; + } else if (!linkInfo.isAnchor && nodes[i].nodeType === 1) { + // just use text nodes from listBetween + } else if (!linkInfo.isAnchor && i === 0) { + text += nodes[i].textContent; + } else if (!linkInfo.isAnchor && i === nodes.length - 1) { + text += nodes[i].textContent; + } else { + text += nodes[i].textContent; + } + } + linkInfo.text = text.replace(this.context.invoke('HelperPlugin.getRegex', 'space', 'g'), ' '); + } + + linkInfo.needLabel = !linkInfo.text.length; + } + + var def = $.Deferred(); + var linkDialog = new LinkDialog(this.options.parent, { + onClose: function () { + setTimeout(function () { + self.context.invoke('editor.focus'); + }); + } + }, + _.omit(linkInfo, 'range') + ); + + linkDialog.on('save', this, this._wrapCommand(function (newLinkInfo) { + var isCollapsed = linkInfo.range.isCollapsed(); + linkInfo.range.select(); + var $anchor; + if (linkInfo.isAnchor) { + $anchor = $(dom.ancestor(r.sc, dom.isAnchor)); + $anchor.css(newLinkInfo.style || {}); + if (newLinkInfo.isNewWindow) { + $anchor.attr('target', '_blank'); + } else { + $anchor.removeAttr('target'); + } + } else { + self.context.invoke('editor.saveRange'); + def.resolve(_.clone(newLinkInfo)); + var range = self.context.invoke('editor.createRange'); + var anchor = dom.ancestor(range.sc.childNodes[range.so] || range.sc, dom.isAnchor); + $anchor = $(anchor); + if (isCollapsed) { + // move the range just after the link + var point = dom.nextPoint({ + node: anchor, + offset: dom.nodeLength(anchor), + }); + range = self.context.invoke('editor.setRange', point.node, point.offset); + range.select(); + } else { + $anchor.selectContent(); + } + } + if ((dom.isImg(media) || dom.isIcon(media)) && !$anchor.find(media).length) { + $(media).remove(); + } + $anchor.attr('class', newLinkInfo.className); + $anchor.attr('href', newLinkInfo.url); + self.context.invoke('editor.saveRange'); + self.context.invoke('editor.saveTarget', $anchor[0]); + self.context.triggerEvent('focusnode', $anchor[0]); + })); + linkDialog.on('closed', this, function () { + def.reject(); + this.context.invoke('editor.restoreRange'); + this.context.invoke('LinkPopover.update'); + }); + + linkDialog.open(); + return def.promise(); + }, + /** + * Remove the current link, keep its contents. + * + * @override + */ + unlink: function () { + var rng = this.context.invoke('editor.createRange'); + var anchor = rng.sc; + while (anchor && anchor.tagName !== 'A') { + anchor = anchor.parentElement; + } + if (!anchor) { + this.context.invoke('editor.hidePopover'); + return; + } + var startAndEndInvisible = this.context.invoke('HelperPlugin.getRegex', 'startAndEndInvisible', 'g'); + anchor.innerHTML = anchor.innerHTML.replace(startAndEndInvisible, ''); + var $contents = $(anchor).contents(); + $(anchor).before($contents).remove(); + + this.context.invoke('editor.hidePopover'); + + var start = $contents[0]; + var end = $contents.last()[0]; + rng = this.context.invoke('editor.setRange', start, 0, end, dom.nodeLength(end)); + rng.select(); + this.editable.normalize(); + this.context.invoke('editor.saveRange'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _addButtons: function () { + var self = this; + this._super(); + + this.context.memo('help.LinkPlugin.show', this.options.langInfo.help['linkDialog.show']); + + this.context.memo('button.linkPlugin', function () { + return self.context.invoke('buttons.button', { + contents: self.ui.icon(self.options.icons.link), + tooltip: self.lang.link.link + self.context.invoke('buttons.representShortcut', 'LinkPlugin.show'), + click: self.context.createInvokeHandler('LinkPlugin.show') + }).render(); + }); + }, + /** + * @param {jQueryEvent} e + */ + _onDblclick: function (e) { + if (dom.isAnchor(e.target)) { + this.show(); + } + }, +}); + +var LinkPopover = Plugins.linkPopover.extend({ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Prevent links without text: replace empty text with the word "Label". + * + * @param {DOM} anchor + * @param {bool} select + */ + fillEmptyLink: function (anchor, select) { + if (dom.isAnchor(anchor)) { + var text = _t('Label'); + if (this.context.invoke('HelperPlugin.getRegex', 'onlyEmptySpace').test(anchor.innerHTML)) { + $(anchor).contents().remove(); + $(anchor).append(this.document.createTextNode(text)); + } + var rng = this.context.invoke('editor.createRange'); + if (select && anchor.innerHTML === text) { + this._cleanLastLink(); + rng.sc = rng.ec = anchor.firstChild; + rng.so = 0; + rng.eo = dom.nodeLength(rng.sc); + rng.select(); + this.context.invoke('editor.saveRange'); + } + if (select) { + this.lastAnchor = anchor; + } + } + }, + /** + * @override + */ + hide: function () { + this._cleanLastLink(); + this._super(); + }, + /** + * @override + */ + update: function () { + var rng = this.context.invoke('editor.createRange'); + var anchor = dom.ancestor(rng.sc, dom.isAnchor); + if (anchor && anchor === dom.ancestor(rng.ec, dom.isAnchor)) { + anchor = dom.ancestor(rng.sc, dom.isAnchor); + if (!$(anchor).is(':o_editable')) { + this.hide(); + return; + } + } else { + anchor = false; + } + + if ($(anchor).data('toggle') === 'tab') { + anchor = false; + } + + if (!this.options.displayPopover(anchor)) { + anchor = false; + } + + if (anchor !== this.lastAnchor) { + this._cleanLastLink(); + } + + if (!anchor) { + this.hide(); + return; + } + + if (dom.isAnchor(anchor)) { + this.lastAnchor = anchor; + } + + + var $target = $(anchor); + if (!$target.data('show_tooltip')) { + $target.data('show_tooltip', true); + setTimeout(function () { + $target.tooltip({ + title: _t('Double-click to edit'), + trigger: 'manuel', + container: this.document.body, + placement: 'top' + }).tooltip('show'); + setTimeout(function () { + $target.tooltip('dispose'); + }, 2000); + }, 400); + } + + var innerHTML = anchor.innerHTML.replace(' ', '\u00A0').replace('​', '\u200B'); + + // prevent links without text + this.fillEmptyLink(anchor, true); + + // add invisible char to prevent the carret to leave the link at the begin or end + if (innerHTML[0] !== '\u200B') { + var before = this.document.createTextNode('\u200B'); + $(anchor).prepend(before); + } + if (innerHTML[innerHTML.length - 1] !== '\u200B') { + var after = this.document.createTextNode('\u200B'); + $(anchor).append(after); + } + + anchor.normalize(); + + var href = $(anchor).attr('href'); + this.$popover.find('a').attr('href', href).html(href); + + var pos = $(anchor).offset(); + var posContainer = $(this.options.container).offset(); + pos.left = pos.left - posContainer.left + 10; + pos.top = pos.top - posContainer.top + $(anchor).outerHeight(); + + this.$popover.css({ + display: 'block', + left: pos.left, + top: pos.top, + }); + + this.context.layoutInfo.editor.after(this.$popover); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Clean the last link and prevent links without text. + * + * @private + */ + _cleanLastLink: function () { + if (this.lastAnchor) { + this.fillEmptyLink(this.lastAnchor); + + var range = this.context.invoke('editor.createRange'); + var rangeChange; + var innerHTML = this.lastAnchor.innerHTML.replace(' ', '\u00A0').replace('​', '\u200B'); + // prevent links without text + if ( + this.$editable.has(this.lastAnchor).length && + this.context.invoke('HelperPlugin.getRegexBlank', { + space: true, + invisible: true, + nbsp: true, + }).test(innerHTML) + ) { + $(this.lastAnchor).contents().remove(); + $(this.lastAnchor).append(this.document.createTextNode(_t('Label'))); + range.sc = range.ec = this.lastAnchor.firstChild; + range.so = 0; + range.eo = dom.nodeLength(range.sc); + rangeChange = true; + } + var firstChild = this.context.invoke('HelperPlugin.firstLeaf', this.lastAnchor); + if ( + !firstChild.tagName && + this.context.invoke('HelperPlugin.getRegex', 'startInvisible').test(firstChild.textContent) + ) { + firstChild.textContent = firstChild.textContent.replace(this.context.invoke('HelperPlugin.getRegex', 'startInvisible'), ''); + if (range.sc === firstChild && range.so) { + range.so -= 1; + rangeChange = true; + } + if (range.ec === firstChild && range.eo) { + range.eo -= 1; + rangeChange = true; + } + } + var lastChild = this.context.invoke('HelperPlugin.lastLeaf', this.lastAnchor); + if ( + lastChild.textContent.length > 1 && + !lastChild.tagName && + this.context.invoke('HelperPlugin.getRegex', 'endInvisible').test(lastChild.textContent) + ) { + lastChild.textContent = lastChild.textContent.replace(this.context.invoke('HelperPlugin.getRegex', 'endInvisible'), ''); + if (range.sc === lastChild && range.so > dom.nodeLength(lastChild)) { + range.so = dom.nodeLength(lastChild); + rangeChange = true; + } + if (range.ec === lastChild && range.eo > dom.nodeLength(lastChild)) { + range.eo = dom.nodeLength(lastChild); + rangeChange = true; + } + } + if (rangeChange) { + range.select(); + this.context.invoke('editor.saveRange'); + } + this.lastAnchor = null; + } + }, +}); + +registry.add('LinkPlugin', LinkPlugin) + .add('LinkPopover', LinkPopover) + .add('linkDialog', null) + .add('linkPopover', null) + .add('autoLink', null); + +return { + LinkPlugin: LinkPlugin, + LinkPopover: LinkPopover, +}; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js new file mode 100644 index 00000000000..f136b25830f --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js @@ -0,0 +1,1232 @@ +odoo.define('web_editor.wysiwyg.plugin.media', function (require) { +'use strict'; + +var core = require('web.core'); +var weWidgets = require('wysiwyg.widgets'); +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var registry = require('web_editor.wysiwyg.plugin.registry'); +var Plugins = require('web_editor.wysiwyg.plugins'); +var wysiwygTranslation = require('web_editor.wysiwyg.translation'); +var wysiwygOptions = require('web_editor.wysiwyg.options'); + +var _t = core._t; + +var dom = $.summernote.dom; +var ui = $.summernote.ui; + +//-------------------------------------------------------------------------- +// Media (for image, video, icon, document) +//-------------------------------------------------------------------------- + +/** + * Return true if the node is a media (image, icon, document or video). + * + * @param {Node} node + * @returns {Boolean} + */ +dom.isMedia = function (node) { + return dom.isImg(node) || + dom.isIcon(node) || + dom.isDocument(node) || + dom.isVideo(node); +}; + +var MediaPlugin = AbstractPlugin.extend({ + events: { + 'summernote.mousedown': '_onMouseDown', + 'summernote.keydown': '_onKeydown', + 'summernote.keyup': '_onKeyup', + 'summernote.scroll': '_onScroll', + 'summernote.disable': '_onDisable', + 'summernote.change': '_onChange', + 'summernote.codeview.toggled': '_onToggled', + 'dblclick .note-editable': '_onDblclick', + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Open the image dialog and listen to its saved/closed events. + */ + showImageDialog: function () { + this.context.invoke('editor.saveRange'); + var media = this.context.invoke('editor.restoreTarget'); + + var mediaDialog = new weWidgets.MediaDialog(this.options.parent, {}, + $(media).clone()[0] + ); + + mediaDialog.on('saved', this, function (data) { + this.insertMedia(media, data); + }); + mediaDialog.on('closed', this, function () { + this.context.invoke('editor.restoreRange'); + }); + mediaDialog.open(); + }, + /** + * Remove the current target media and hide its popover. + */ + removeMedia: function () { + this.context.invoke('editor.beforeCommand'); + var target = this.context.invoke('editor.restoreTarget'); + var point = this.context.invoke('HelperPlugin.removeBlockNode', target); + var rng = this.context.invoke('editor.setRange', point.node, point.offset); + rng.normalize().select(); + this.context.invoke('editor.saveRange'); + this.context.invoke('editor.clearTarget'); + this.hidePopovers(); + this.context.invoke('editor.afterCommand'); + }, + /** + * Update the target media and its popover. + * + * @param {Node} target + */ + update: function (target) { + if (!target) { + return; + } + if (!this.options.displayPopover(target)) { + return; + } + + this.lastPos = this.context.invoke('HelperPlugin.makePoint', target, $(target).offset()); + + this.context.triggerEvent('focusnode', target); + + if (!dom.isMedia(target)) { + return; + } + + this.context.invoke('editor.saveTarget', target); + + var $target = $(target); + if (!$target.data('show_tooltip')) { + $target.data('show_tooltip', true); + setTimeout(function () { + $target.tooltip({ + title: _t('Double-click to edit'), + trigger: 'manuel', + container: this.document.body, + }).tooltip('show'); + setTimeout(function () { + $target.tooltip('dispose'); + }, 2000); + }, 400); + } + + if (dom.isImg(target)) { + this.context.invoke('ImagePlugin.show', target, this.mousePosition); + } else if (dom.isIcon(target)) { + this.context.invoke('IconPlugin.show', target, this.mousePosition); + } else if (dom.isVideo(target)) { + this.context.invoke('VideoPlugin.show', target, this.mousePosition); + } else if (dom.isDocument(target)) { + this.context.invoke('DocumentPlugin.show', target, this.mousePosition); + } + }, + /** + * Hide all open popovers. + * Warning: removes the saved target. + */ + hidePopovers: function () { + var media = this.context.invoke('editor.restoreTarget'); + this.context.invoke('HandlePlugin.hide'); + this.context.invoke('ImagePlugin.hide'); + this.context.invoke('IconPlugin.hide'); + this.context.invoke('VideoPlugin.hide'); + this.context.invoke('DocumentPlugin.hide'); + this.context.invoke('editor.saveTarget', media); + }, + /** + * Insert or replace a media. + * + * @param {Node} previous the media to replace, if any + * @param {Object} data contains the media to insert + */ + insertMedia: function (previous, data) { + var newMedia = data.media; + this._wrapCommand(function () { + this.$editable.focus(); + var rng = this.context.invoke('editor.createRange'); + var point; + + if (newMedia.tagName === "IMG") { + $(newMedia).one('load error abort', this.updatePopoverAfterEdit.bind(this, newMedia)); + } + + if (previous) { + this.context.invoke('editor.clearTarget'); + var start = previous.parentNode; + rng = this.context.invoke('editor.setRange', start, _.indexOf(start.childNodes, previous)); + if (previous.tagName === "IMG" && $(previous).hasClass('img-fluid')) { + $(newMedia).addClass('img img-fluid mx-auto'); + } + + if (dom.isVideo(previous) || dom.isVideo(newMedia)) { + var doNotInsertP = previous.tagName === newMedia.tagName; + point = this.context.invoke('HelperPlugin.removeBlockNode', previous, doNotInsertP); + if (!rng.sc.parentNode || !rng.sc.childNodes[rng.so]) { + rng = this.context.invoke('editor.setRange', point.node, point.offset); + } + previous = null; + } + rng.select(); + this.hidePopovers(); + } + + if (dom.isVideo(newMedia)) { + this.context.invoke('HelperPlugin.insertBlockNode', newMedia); + } else { + rng = this.context.invoke('editor.createRange'); + point = rng.getStartPoint(); + if (!rng.isCollapsed()) { + point = this.context.invoke('HelperPlugin.deleteBetween', point, rng.getEndPoint()); + } + + if (point.node.tagName) { + if (previous) { + $(previous).replaceWith(newMedia); + } else if (dom.isVoid(point.node)) { + point.node.parentNode.insertBefore(newMedia, point.node); + } else { + var node = point.node.childNodes[point.offset]; + if (point.node.tagName === 'BR') { + $(point.node).replaceWith(newMedia); + } else if (node && node.tagName === 'BR') { + $(node).replaceWith(newMedia); + } else { + point.node.insertBefore(newMedia, node || null); + } + } + if (!newMedia.previousSibling) { + $(newMedia).before(this.document.createTextNode('\u200B'), newMedia); + } + if (!newMedia.nextSibling) { + $(newMedia).after(this.document.createTextNode('\u200B'), newMedia); + } + } else { + var next = this.document.createTextNode(point.node.textContent.slice(point.offset)); + point.node.textContent = point.node.textContent.slice(0, point.offset); + + $(point.node).after(next).after(newMedia); + point.node.parentNode.normalize(); + if (!newMedia.previousSibling) { + $(newMedia).before(this.document.createTextNode('\u200B'), newMedia); + } + if (!newMedia.nextSibling) { + $(newMedia).after(this.document.createTextNode('\u200B'), newMedia); + } + rng = this.context.invoke('editor.setRange', newMedia.nextSibling || newMedia, 0); + rng.normalize().select(); + } + } + this.context.invoke('editor.saveRange'); + this.context.invoke('editor.saveTarget', newMedia); + this.context.triggerEvent('focusnode', newMedia); + this.context.invoke('UnbreakablePlugin.secureArea', newMedia); + + this.updatePopoverAfterEdit(newMedia); + })(); + }, + /** + * Update the media's popover and its position after editing the media. + * + * @param {Node} media + */ + updatePopoverAfterEdit: function (media) { + this.mousePosition = { + pageX: $(media).offset().left + $(media).width() / 2, + pageY: $(media).offset().top + $(media).height() / 2, + }; + $(media).trigger('mousedown').trigger('mouseup'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add the media popovers' buttons: + * - Replacement + * - Removal + * - Alignment + * - Padding + * + * @override + */ + _addButtons: function () { + var self = this; + this._super(); + + this.context.memo('button.mediaPlugin', function () { + return self.context.invoke('buttons.button', { + contents: self.ui.icon(self.options.icons.picture), + tooltip: self.lang.image.image, + click: self.context.createInvokeHandler('MediaPlugin.showImageDialog') + }).render(); + }); + + this.context.memo('button.removePluginMedia', function () { + return self.context.invoke('buttons.button', { + contents: self.ui.icon(self.options.icons.trash), + tooltip: self.lang.image.remove, + click: self._wrapCommand(function () { + this.context.invoke('MediaPlugin.removeMedia'); + }) + }).render(); + }); + + _.each(['left', 'center', 'right', 'none'], function (align) { + var alignName = _.str.camelize('align_' + align); + self._createButton(alignName, self.options.icons[alignName], self.lang.image[alignName], function () { + var $target = $(self.context.invoke('editor.restoreTarget')); + $target.css('float', '').removeClass('mx-auto pull-right pull-left'); + if (align === 'center') { + $target.addClass('mx-auto'); + } else if (align !== 'none') { + $target.addClass('pull-' + align); + } + }); + }); + + var padding = [null, 'padding-small', 'padding-medium', 'padding-large', 'padding-xl']; + var zipped = _.zip(padding, this.lang.image.paddingList); + var values = _.map(zipped, function (z) { + return { + value: z[0], + string: z[1], + }; + }); + this._createDropdownButton('padding', this.options.icons.padding, this.lang.image.padding, values); + }, + /** + * Select the target media based on the + * currently saved target or on the current range. + * + * @private + * @param {Node} [target] optional + * @returns {Node} target + */ + _selectTarget: function (target) { + if (!target) { + target = this.context.invoke('editor.restoreTarget'); + } + + if (this.context.isDisabled()) { + this.hidePopovers(); + this.context.invoke('editor.clearTarget'); + return target; + } + var range = this.context.invoke('editor.createRange'); + if (!target && range.isCollapsed()) { + target = range.sc.childNodes[range.so] || range.sc; + } + if (!target || !dom.isMedia(target)) { + this.hidePopovers(); + this.context.invoke('editor.clearTarget'); + return target; + } + + while (target.parentNode && dom.isMedia(target.parentNode)) { + target = target.parentNode; + } + + if (!this.options.isEditableNode(target)) { + if (!target.parentNode) { + target = this.editable; + } + this.hidePopovers(); + this.context.invoke('editor.clearTarget'); + return target; + } + + this.context.invoke('editor.saveTarget', target); + this.context.triggerEvent('focusnode', target); + + return target; + }, + /** + * Select the target media on the right (or left) + * of the currently selected target media. + * + * @private + * @param {Node} target + * @param {Boolean} left + */ + _moveTargetSelection: function (target, left) { + if (!target || !dom.isMedia(target)) { + return; + } + var range = this.context.invoke('editor.createRange'); + var $contentEditable; + + if ( + range.sc.tagName && $.contains(target, range.sc) && + $(range.sc).hasClass('o_fake_editable') && + left === !range.sc.previousElementSibling + ) { + $contentEditable = $(range.sc).closest('[contentEditable]'); + if ($(target).closest('[contentEditable]')[0] !== $contentEditable[0]) { + $contentEditable.focus(); + } + this.context.invoke('editor.saveRange'); + return; + } + + var next = this.context.invoke('HelperPlugin.makePoint', target, 0); + if (left) { + if (dom.isVideo(target)) { + next = this.context.invoke('HelperPlugin.makePoint', target.firstElementChild, 0); + } else { + next = dom.prevPointUntil(next, function (point) { + return point.node !== target && !$.contains(target, point.node); + }) || next; + } + } else { + if (dom.isVideo(target)) { + next = this.context.invoke('HelperPlugin.makePoint', target.lastElementChild, 0); + } else { + next = dom.nextPointUntil(next, function (point) { + return point.node !== target && !$.contains(target, point.node); + }) || next; + } + } + + $contentEditable = $(next.node).closest('[contentEditable]'); + if ($(target).closest('[contentEditable]')[0] !== $contentEditable[0]) { + // move the focus only if the new contentEditable is not the same (avoid scroll up) + // (like in the case of a video, which uses two contentEditable in the media, so as to write text) + $contentEditable.focus(); + } + + range = this.context.invoke('editor.setRange', next.node, next.offset); + range.select(); + + this.context.invoke('editor.saveRange'); + }, + + //-------------------------------------------------------------------------- + // handle + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onDisable: function () { + this.hidePopovers(); + this.context.invoke('editor.clearTarget'); + }, + /** + * @private + * @param {jQueryEvent} e + */ + _onDblclick: function (e) { + if (dom.isMedia(e.target)) { + var target = this._selectTarget(e.target); + this._moveTargetSelection(target); + this.showImageDialog(); + } + }, + /** + * @private + **/ + _onKeydown: function () { + this.context.invoke('editor.clearTarget'); + }, + /** + * @private + * @param {SummernoteEvent} se + * @param {jQueryEvent} e + */ + _onKeyup: function (se, e) { + var target = this._selectTarget(); + var range = this.context.invoke('editor.createRange'); + if (e.keyCode === 37) { + var point = dom.prevPoint(range.getStartPoint()); + if (dom.isMedia(point.node)) { + target = point.node; + } + } + this._moveTargetSelection(target, e.keyCode === 37); + return this.update(target); + }, + /** + * @private + */ + _onScroll: function () { + var target = this._selectTarget(); + if (this.lastPos && this.lastPos.target === target && $(target).offset()) { + var newTop = $(target).offset().top; + var movement = this.lastPos.offset.top - newTop; + if (movement && this.mousePosition) { + this.mousePosition.pageY -= movement; + } + } + return this.update(target); + }, + /** + * @private + */ + _onChange: function () { + var target = this._selectTarget(); + this._moveTargetSelection(target); + if (!this.$editable.has(target).length) { + return; + } + return this.update(target); + }, + /** + * @private + * @param {SummernoteEvent} se + * @param {jQueryEvent} e + */ + _onMouseDown: function (se, e) { + var target = this._selectTarget(e.target); + if (target && dom.isMedia(target)) { + var pos = $(target).offset(); + + if (e.pageX) { + this.mousePosition = { + pageX: e.pageX, + pageY: e.pageY, + }; + } else { + // for testing triggers + this.mousePosition = { + pageX: pos.left, + pageY: pos.top, + }; + } + + var width = $(target).width(); + // we put the cursor to the left if we click in the first tier of the media + var left = this.mousePosition.pageX < (pos.left + width / 3); + this._moveTargetSelection(target, left); + + this.update(target); + e.preventDefault(); + } else { + this.mousePosition = {}; + } + }, + /** + * @private + */ + _onToggled: function () { + this.update(); + }, +}); + +_.extend(wysiwygOptions.icons, { + alignCenter: 'note-icon-align-center', + alignNone: wysiwygOptions.icons.alignJustify, + picture: 'fa fa-file-image-o', +}); +_.extend(wysiwygTranslation.image, { + alignRight: wysiwygTranslation.image.floatRight, + alignCenter: _t('Align center'), + alignLeft: wysiwygTranslation.image.floatLeft, + alignNone: wysiwygTranslation.image.floatNone, +}); + +//-------------------------------------------------------------------------- +// Abstract +//-------------------------------------------------------------------------- + +var AbstractMediaPlugin = AbstractPlugin.extend({ + targetType: null, + initialize: function () { + this._super.apply(this, arguments); + this.$popover = this.ui.popover({ + className: 'note-' + this.targetType + '-popover', + }) + .render().appendTo(this.options.container); + var $content = this.$popover.find('.popover-content, .note-popover-content'); + this.context.invoke('buttons.build', $content, this.options.popover[this.targetType]); + this.options.POPOVER_MARGIN = 15; + }, + destroy: function () { + this.$popover.remove(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Hide this popover. + */ + hide: function () { + this.$popover.hide(); + }, + /** + * Show this popover. + * + * @param {Node} target + * @param {Object} mousePosition {pageX: Number, pageY: Number} + */ + show: function (target, mousePosition) { + this._popoverPosition(target, mousePosition); + ui.toggleBtnActive(this.$popover.find('a, button'), false); + var $target = $(target); + + var float = $target.css('float'); + if (float === 'none' && $target.hasClass('mx-auto')) { + float = 'center'; + } + var floatIcon = this.options.icons[_.str.camelize('align_' + (float !== 'none' ? float : 'justify'))]; + ui.toggleBtnActive(this.$popover.find('.note-float button:has(.' + floatIcon + ')'), true); + + var padding = (($target.attr('class') || '').match(/(^| )(padding-[^\s]+)( |$)/) || ['fa-1x'])[2]; + ui.toggleBtnActive(this.$popover.find('.note-padding a:has(li[data-value="' + padding + '"])'), true); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Takes the left and top position of the popover and an optional margin + * and returns updated positions to force the popover to fit the editor container. + * + * @private + * @param {Number} left + * @param {Number} top + * @param {Number} [margin] optional + * @returns {Object} {left: Number, top: Number} + */ + _popoverFitEditor: function (left, top, margin) { + margin = margin || this.options.POPOVER_MARGIN; + + var $container = $(this.options.container); + var containerWidth = $container.width(); + var containerHeight = $container.height(); + + var popoverWidth = this.$popover.width(); + var popoverHeight = this.$popover.height(); + + var isBeyondXBounds = left + popoverWidth >= containerWidth - margin; + var isBeyondYBounds = top + popoverHeight >= containerHeight - margin; + return { + left: isBeyondXBounds ? containerWidth - popoverWidth - margin : left, + top: isBeyondYBounds ? top = containerHeight - popoverHeight - margin : (top > 0 ? top : margin), + }; + }, + /** + * Update the position of the popover in CSS. + * + * @private + * @param {Node} target + * @param {Object} mousePosition {pageX: Number, pageY: Number} + */ + _popoverPosition: function (target, mousePosition) { + var pos = $(this.options.container).offset(); + pos.left = mousePosition.pageX - pos.left + this.options.POPOVER_MARGIN; + pos.top = mousePosition.pageY - pos.top + this.options.POPOVER_MARGIN; + + var popoverPos = this._popoverFitEditor(pos.left, pos.top); + this.$popover.css({ + display: 'block', + left: popoverPos.left, + top: popoverPos.top, + }); + }, + /** + * Override to return whether the target is a media (specific to its class) or not. + * + * @private + * @param {Node} target + */ + _isMedia: function (target) {}, +}); + +//-------------------------------------------------------------------------- +// Image +//-------------------------------------------------------------------------- + +var ImagePlugin = AbstractMediaPlugin.extend({ + targetType: 'image', + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Open the crop image dialog and listen to its saved/closed events. + */ + cropImageDialog: function () { + this.context.invoke('editor.saveRange'); + + var media = this.context.invoke('editor.restoreTarget'); + var cropImageDialog = new weWidgets.CropImageDialog(this.options.parent, {}, + $(media).clone() + ); + cropImageDialog.on('saved', this, function (data) { + this.context.invoke('MediaPlugin.insertMedia', media, data); + }); + cropImageDialog.on('closed', this, function () { + this.context.invoke('editor.restoreRange'); + }); + + cropImageDialog.open(); + }, + /** + * Open the alt dialog (change image title and alt) and listen to its saved/closed events. + */ + altDialg: function () { + this.context.invoke('editor.saveRange'); + + var media = this.context.invoke('editor.restoreTarget'); + var altDialog = new weWidgets.AltDialog(this.options.parent, {}, + $(media).clone() + ); + altDialog.on('saved', this, this._wrapCommand(function (data) { + $(media).attr('alt', $(data.media).attr('alt')) + .attr('title', $(data.media).attr('title')) + .trigger('content_changed'); + })); + altDialog.on('closed', this, function () { + this.context.invoke('editor.restoreRange'); + }); + + altDialog.open(); + }, + /** + * Show the image target's popover. + * + * @override + * @param {Node} target + * @param {Object} mousePosition {pageX: Number, pageY: Number} + */ + show: function (target, mousePosition) { + var self = this; + this._super.apply(this, arguments); + var $target = $(target); + + this.context.invoke('HandlePlugin.update', target); + + _.each(this.options.icons.imageShape, function (icon, className) { + var thisIconSel = '.note-imageShape button:has(.' + + icon.replace(self.context.invoke('HelperPlugin.getRegex', 'space', 'g'), '.') + + ')'; + ui.toggleBtnActive(self.$popover.find(thisIconSel), $target.hasClass(className)); + }); + + var size = (($target.attr('style') || '').match(/width:\s*([0-9]+)%/i) || [])[1]; + ui.toggleBtnActive(this.$popover.find('.note-imagesize button:contains(' + (size ? size + '%' : this.lang.image.imageSizeAuto) + ')'), true); + + ui.toggleBtnActive(this.$popover.find('.note-cropImage button'), $target.hasClass('o_cropped_img_to_save')); + + // update alt button in popover + if ($target.attr('alt')) { + var $altLabel = $(this.altBtnPrefix).text(function (i, v) { + var newText = v + '\u00A0' + $target.attr('alt'); + return $.trim(newText).substring(0, 30).trim(this) + "..."; + }); + this.$popover.find('.note-alt button').contents().replaceWith($altLabel); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Save cropped images. + * + * @private + * @returns {Promise} + */ + _saveCroppedImages: function () { + var self = this; + var defs = this.$editables.find('.o_cropped_img_to_save').map(function () { + var $croppedImg = $(this); + $croppedImg.removeClass('o_cropped_img_to_save'); + var resModel = $croppedImg.data('crop:resModel'); + var resID = $croppedImg.data('crop:resID'); + var cropID = $croppedImg.data('crop:id'); + var mimetype = $croppedImg.data('crop:mimetype'); + var originalSrc = $croppedImg.data('crop:originalSrc'); + var datas = $croppedImg.attr('src').split(',')[1]; + if (!cropID) { + var name = originalSrc + '.crop'; + return self._rpc({ + model: 'ir.attachment', + method: 'create', + args: [{ + res_model: resModel, + res_id: resID, + name: name, + datas_fname: name, + datas: datas, + mimetype: mimetype, + url: originalSrc, // To save the original image that was cropped + }], + }).then(function (attachmentID) { + return self._rpc({ + model: 'ir.attachment', + method: 'generate_access_token', + args: [ + [attachmentID], + ], + }).then(function (access_token) { + $croppedImg.attr('src', '/web/image/' + attachmentID + '?access_token=' + access_token[0]); + }); + }); + } else { + return self._rpc({ + model: 'ir.attachment', + method: 'write', + args: [ + [cropID], { + datas: datas, + }, + ], + }); + } + }).get(); + return $.when.apply($, defs); + }, + /** + * Add the image popovers' buttons: + * From _super: + * - Replacement + * - Removal + * - Alignment + * - Padding + * From this override: + * - Shape + * - Crop + * - Alt + * - Size + * + * @private + */ + _addButtons: function () { + var self = this; + this._super(); + // add all shape buttons if this option is active + this.context.memo('button.imageShape', function () { + var $el = $(); + _.each(['rounded', 'rounded-circle', 'shadow', 'img-thumbnail'], function (shape) { + $el = $el.add(self._createToggleButton(null, self.options.icons.imageShape[shape], self.lang.image.imageShape[shape], shape)); + }); + return $el; + }); + this.context.memo('button.cropImage', function () { + return self.context.invoke('buttons.button', { + contents: self.ui.icon(self.options.icons.cropImage), + tooltip: self.lang.image.cropImage, + click: self.context.createInvokeHandler('ImagePlugin.cropImageDialog') + }).render(); + }); + this.altBtnPrefix = '<b>' + self.lang.image.alt + '</b>'; + this.context.memo('button.alt', function () { + return self.context.invoke('buttons.button', { + contents: self.altBtnPrefix, + click: self.context.createInvokeHandler('ImagePlugin.altDialg') + }).render(); + }); + this.context.memo('button.imageSizeAuto', function () { + return self.context.invoke('buttons.button', { + contents: '<span class="note-iconsize-10">' + self.lang.image.imageSizeAuto + '</span>', + click: self._wrapCommand(function () { + var target = this.context.invoke('editor.restoreTarget'); + $(target).css({ + width: '', + height: '' + }); + }) + }).render(); + }); + }, + /** + * Return true if the target is an image. + * + * @override + * @param {Node} target + * @returns {Boolean} true if the target is an image + */ + _isMedia: function (target) { + return dom.isImg(target); + }, +}); + +_.extend(wysiwygOptions.icons, { + padding: 'fa fa-plus-square-o', + cropImage: 'fa fa-crop', + imageShape: { + rounded: 'fa fa-square', + 'rounded-circle': 'fa fa-circle-o', + shadow: 'fa fa-sun-o', + 'img-thumbnail': 'fa fa-picture-o', + }, +}); +_.extend(wysiwygTranslation.image, { + padding: _t('Padding'), + paddingList: [_t('None'), _t('Small'), _t('Medium'), _t('Large'), _t('Xl')], + imageSizeAuto: _t('Auto'), + cropImage: _t('Crop image'), + imageShape: { + rounded: _t('Shape: Rounded'), + 'rounded-circle': _t('Shape: Circle'), + shadow: _t('Shape: Shadow'), + 'img-thumbnail': _t('Shape: Thumbnail'), + }, + alt: _t('Description:'), +}); + +//-------------------------------------------------------------------------- +// Video +//-------------------------------------------------------------------------- + +/** + * Return true if the node is a video. + * + * @param {Node} node + * @returns {Boolean} + */ +dom.isVideo = function (node) { + node = node && !node.tagName ? node.parentNode : node; + return (node.tagName === "IFRAME" || node.tagName === "DIV") && + (node.parentNode.className && node.parentNode.className.indexOf('media_iframe_video') !== -1 || + node.className.indexOf('media_iframe_video') !== -1); +}; + +var VideoPlugin = AbstractMediaPlugin.extend({ + targetType: 'video', + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Show the video target's popover. + * + * @override + * @param {Node} target + * @param {Object} mousePosition {pageX: Number, pageY: Number} + */ + show: function (target, mousePosition) { + if (target.tagName === "DIV" && target.className.indexOf('css_editable_mode_display') !== -1) { + target = target.parentNode; + this.context.invoke('editor.saveTarget', target); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return true if the target is a video. + * + * @override + * @param {Node} target + */ + _isMedia: function (target) { + return dom.isVideo(target); + }, +}); + +//-------------------------------------------------------------------------- +// Icons: Icon Awsome (and other with themes) +//-------------------------------------------------------------------------- + +/** + * Return true if the node is an icon. + * + * @param {Node} node + * @returns {Boolean} + */ +dom.isIcon = function (node) { + node = node && !node.tagName ? node.parentNode : node; + return node && node.className && node.className.indexOf(' fa-') !== -1; +}; + +var IconPlugin = AbstractMediaPlugin.extend({ + targetType: 'icon', + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Hide this icon's popover. + * + * @override + */ + hide: function () { + this._super(); + this.removeSelectedClass(this.lastIcon); + }, + /** + * Show the icon target's popover. + * + * @override + * @param {Node} target + */ + show: function (target) { + this.removeSelectedClass(this.lastIcon); + this.lastIcon = target; + + this._super.apply(this, arguments); + + var $target = $(target); + ui.toggleBtnActive(this.$popover.find('.note-faSpin button'), $target.hasClass('fa-spin')); + var faSize = parseInt((($target.attr('style') || '').match(/font-size:\s*([0-9](em|px))(;|$)/) || [])[1] || 0); + if (!faSize) { + faSize = (($target.attr('class') || '').match(/(^| )fa-([0-9])x( |$)/) || [])[2]; + } + ui.toggleBtnActive(this.$popover.find('.note-faSize a[data-value="fa-' + faSize + 'x"]'), true); + this.addSelectedClass(target); + }, + /** + * Add a class to the current target so as to show it's selected. + * + * @param {Node} target + */ + addSelectedClass: function (target) { + if (target) { + $(target).addClass('o_we_selected_image'); + } + }, + /** + * Remove a class to the current target so as to show it's not selected. + * + * @param {Node} target + */ + removeSelectedClass: function (target) { + if (target) { + $(target).removeClass('o_we_selected_image'); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add the icon popovers' buttons: + * From _super: + * - Replacement + * - Removal + * - Alignment + * - Padding + * From this override: + * - Size + * - Spin + * + * @private + */ + _addButtons: function () { + var self = this; + this._super(); + var values = [{ + value: '', + string: '1x', + }, + { + value: 'fa-2x', + string: '2x', + }, + { + value: 'fa-3x', + string: '3x', + }, + { + value: 'fa-4x', + string: '4x', + }, + { + value: 'fa-5x', + string: '5x', + }, + ]; + var onFaSize = function (e) { + var classNames = _.map(values, function (item) { + return item.value; + }).join(' '); + var $target = $(self.context.invoke('editor.restoreTarget')); + $target.removeClass(classNames); + if ($(e.target).data('value')) { + $target.addClass($(e.target).data('value')); + $target.css('fontSize', ''); + } + }; + this._createDropdownButton('faSize', this.options.icons.faSize, this.lang.image.faSize, values, onFaSize); + this._createToggleButton('faSpin', this.options.icons.faSpin, this.lang.image.faSpin, 'fa-spin'); + }, + /** + * Return true if the target is an icon. + * + * @override + * @param {Node} target + */ + _isMedia: function (target) { + return dom.isIcon(target); + }, + /** + * Update the position of the popover in CSS. + * + * @override + * @param {Node} target + * @param {Object} mousePosition {pageX: Number, pageY: Number} + */ + _popoverPosition: function (target, mousePosition) { + var pos = $(target).offset(); + var posContainer = $(this.options.container).offset(); + pos.left = pos.left - posContainer.left + this.options.POPOVER_MARGIN + parseInt($(target).css('font-size')) + 10; + pos.top = pos.top - posContainer.top + this.options.POPOVER_MARGIN; + + var popoverPos = this._popoverFitEditor(pos.left + 10, pos.top - 15); + this.$popover.css({ + display: 'block', + left: popoverPos.left, + top: popoverPos.top, + }); + }, +}); +_.extend(wysiwygOptions.icons, { + faSize: 'fa fa-expand', + faSpin: 'fa fa-refresh', +}); +_.extend(wysiwygTranslation.image, { + faSize: _t('Icon size'), + faSpin: _t('Spin'), +}); + +//-------------------------------------------------------------------------- +// Media Document +//-------------------------------------------------------------------------- + +/** + * Return true is the node is a document. + * @param {Node} node + * @returns {Boolean} + */ +dom.isDocument = function (node) { + node = node && !node.tagName ? node.parentNode : node; + return node && (node.tagName === "A" && node.className.indexOf('o_image') !== -1); +}; + +var DocumentPlugin = AbstractMediaPlugin.extend({ + targetType: 'document', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return true if the target is an icon. + * + * @override + * @param {Node} target + */ + _isMedia: function (target) { + return dom.isDocument(target); + }, + /** + * Update the position of the popover in CSS. + * + * @override + * @param {Node} target + * @param {Object} mousePosition {pageX: Number, pageY: Number} + */ + _popoverPosition: function (target, mousePosition) { + var pos = $(target).offset(); + var posContainer = $(this.options.container).offset(); + pos.left = pos.left - posContainer.left + this.options.POPOVER_MARGIN; + pos.top = pos.top - posContainer.top + this.options.POPOVER_MARGIN; + + var popoverPos = this._popoverFitEditor(pos.left + 10, pos.top - 15); + this.$popover.css({ + display: 'block', + left: popoverPos.left, + top: popoverPos.top, + }); + }, +}); + +//-------------------------------------------------------------------------- +// Handle (hover image) +//-------------------------------------------------------------------------- + +var HandlePlugin = Plugins.handle.extend({ + /** + * Update the handle. + * + * @param {Node} target + * @returns {Boolean} + */ + update: function (target) { + if (this.context.isDisabled()) { + return false; + } + var isImage = dom.isImg(target); + var $selection = this.$handle.find('.note-control-selection'); + this.context.invoke('imagePopover.update', target); + if (!isImage) { + return isImage; + } + + var $target = $(target); + var pos = $target.offset(); + var posContainer = $selection.closest('.note-handle').offset(); + + // exclude margin + var imageSize = { + w: $target.outerWidth(false), + h: $target.outerHeight(false) + }; + $selection.css({ + display: 'block', + left: pos.left - posContainer.left, + top: pos.top - posContainer.top, + width: imageSize.w, + height: imageSize.h, + }).data('target', $target); // save current target element. + + var src = $target.attr('src'); + var sizingText = imageSize.w + 'x' + imageSize.h; + if (src) { + var origImageObj = new Image(); + origImageObj.src = src; + sizingText += ' (' + this.lang.image.original + ': ' + origImageObj.width + 'x' + origImageObj.height + ')'; + } + $selection.find('.note-control-selection-info').text(sizingText); + this.context.invoke('editor.saveTarget', target); + + return isImage; + }, +}); + +//-------------------------------------------------------------------------- +// add to registry +//-------------------------------------------------------------------------- + +registry.add('MediaPlugin', MediaPlugin) + .add('ImagePlugin', ImagePlugin) + .add('VideoPlugin', VideoPlugin) + .add('IconPlugin', IconPlugin) + .add('DocumentPlugin', DocumentPlugin) + .add('HandlePlugin', HandlePlugin); + +// modules to remove from summernote +registry.add('imagePopover', null) + .add('handle', null); + +return { + MediaPlugin: MediaPlugin, + ImagePlugin: ImagePlugin, + VideoPlugin: VideoPlugin, + IconPlugin: IconPlugin, + DocumentPlugin: DocumentPlugin, +}; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js b/addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js new file mode 100644 index 00000000000..dfd5b109e74 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js @@ -0,0 +1,78 @@ +odoo.define('web_editor.wysiwyg.plugins', function (require) { +'use strict'; + +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var registry = require('web_editor.wysiwyg.plugin.registry'); +var wysiwygOptions = require('web_editor.wysiwyg.options'); + + +var plugins = _.mapObject(wysiwygOptions.modules, function (Module, pluginName) { + var prototype = { + init: function () { + var self = this; + this._super.apply(this, arguments); + var events = _.clone(this.events); + this.summernote.options.modules[pluginName].apply(this, arguments); + _.each(events, function (value, key) { + self.events[key] = value; + }); + }, + }; + _.each(Module.prototype, function (prop, name) { + if (typeof prop === 'function') { + prototype[name] = function () { + return this.summernote.options.modules[pluginName].prototype[name].apply(this, arguments); + }; + } else { + prototype[name] = prop; + } + }); + + var Plugin = AbstractPlugin.extend(prototype).extend({ + destroy: function () { + if (this.shouldInitialize()) { + this._super(); + } + }, + }); + + // override summernote default buttons + registry.add(pluginName, Plugin); + + return Plugin; +}); + +// export table plugin to convert it in module (see editor) + +var $textarea = $('<textarea>'); +$('body').append($textarea); +$textarea.summernote(); +var summernote = $textarea.data('summernote'); + +_.each(['style', 'table', 'typing', 'bullet', 'history'], function (name) { + var prototype = {}; + for (var k in summernote.modules.editor[name]) { + prototype[k] = summernote.modules.editor[name][k]; + } + plugins[name] = AbstractPlugin.extend(prototype); +}); + +var History = summernote.modules.editor.history.constructor; +plugins.history.include({ + init: function (context) { + this._super(context); + History.call(this, this.$editable); + }, +}); + +try { + $textarea.summernote('destroy'); +} catch (e) { + summernote.layoutInfo.editor.remove(); +} +$textarea.remove(); + + +return plugins; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/table.js b/addons/web_editor/static/src/js/wysiwyg/plugin/table.js new file mode 100644 index 00000000000..b015faee43f --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/table.js @@ -0,0 +1,244 @@ +odoo.define('web_editor.wysiwyg.plugin.table', function (require) { +'use strict'; + +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var dom = $.summernote.dom; + + +var TablePlugin = Plugins.table.extend({ + + initialize: function () { + this._super.apply(this, arguments); + var self = this; + // We need setTimeout to make sure to initialize after HelperPlugin and HistoryPlugin + setTimeout(function () { + // contentEditable fail for image and font in table + // user must use right arrow the number of space but without feedback + self.$editable.find('td:has(img, span.fa)').each(function () { + if (this.firstChild && !this.firstChild.tagName) { + var startSpace = self.context.invoke('HelperPlugin.getRegex', 'startSpace'); + this.firstChild.textContent = this.firstChild.textContent.replace(startSpace, ' '); + } + if (this.lastChild && !this.lastChild.tagName) { + var endSpace = self.context.invoke('HelperPlugin.getRegex', 'endSpace'); + this.lastChild.textContent = this.lastChild.textContent.replace(endSpace, ' '); + } + }); + self.context.invoke('HistoryPlugin.clear'); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a new col and + * wrap contents of the new cells in p elements. + * + * @see summernote library + * + * @override + * @param {WrappedRange} rng + * @param {String('left'|'right')} position + */ + addCol: function (rng, position) { + this._super.apply(this, arguments); + var cell = dom.ancestor(rng.commonAncestor(), dom.isCell); + var table = dom.ancestor(cell, function (n) { + return n.tagName === 'TABLE'; + }); + var newColIndex = $(cell)[position === 'right' ? 'next' : 'prev']('td').index() + 1; + $(table).find('td:nth-child(' + newColIndex + ')').contents().wrap('<p></p>'); + }, + /** + * Add a new row and + * wrap contents of the new cells in p elements. + * + * @see summernote library + * + * @override + * @param {WrappedRange} rng + * @param {String('top'|'bottom')} position + */ + addRow: function (rng, position) { + this._super.apply(this, arguments); + var row = dom.ancestor(rng.commonAncestor(), dom.isCell).parentElement; + $(row)[position === 'bottom' ? 'next' : 'prev']('tr').find('td').contents().wrap('<p></p>'); + }, + /** + * Create empty table element and + * wrap the contents of all cells in p elements. + * + * @see summernote library + * + * @override + * @param {Number} rowCount + * @param {Number} colCount + * @returns {Node} table + */ + createTable: function () { + var table = this._super.apply(this, arguments); + $(table).find('td').contents().wrap('<p></p>'); + return table; + }, + /** + * @see summernote library + * + * @override + */ + deleteCol: function () { + var range = this.context.invoke('editor.createRange'); + + // Delete the last remaining column === delete the table + var cell = dom.ancestor(range.commonAncestor(), dom.isCell); + if (cell && !cell.previousElementSibling && !cell.nextElementSibling) { + return this.deleteTable(); + } + var neighbor = cell.previousElementSibling || cell.nextElementSibling; + + this._super.apply(this, arguments); + + // Put the range back on the previous or next cell after deleting + // to allow chain-removing + range = this.context.invoke('editor.createRange'); + if (range.sc.tagName !== 'TD' && neighbor) { + range = this.context.invoke('editor.setRange', neighbor, 0); + range.normalize().select(); + } + }, + /** + * @see summernote library + * + * @override + */ + deleteRow: function () { + var range = this.context.invoke('editor.createRange'); + + // Delete the last remaining row === delete the table + var row = dom.ancestor(range.commonAncestor(), function (n) { + return n.tagName === 'TR'; + }); + if (row && !row.previousElementSibling && !row.nextElementSibling) { + return this.deleteTable(); + } + var neighbor = row.previousElementSibling || row.nextElementSibling; + + this._super.apply(this, arguments); + + // Put the range back on the previous or next row after deleting + // to allow chain-removing + range = this.context.invoke('editor.createRange'); + if (range.sc.tagName !== 'TR' && neighbor) { + range = this.context.invoke('editor.setRange', neighbor, 0); + range.normalize().select(); + } + }, + /** + * Delete the table in range. + */ + deleteTable: function () { + var range = this.context.invoke('editor.createRange'); + var cell = dom.ancestor(range.commonAncestor(), dom.isCell); + var table = $(cell).closest('table')[0]; + + var point = this.context.invoke('HelperPlugin.removeBlockNode', table); + range = this.context.invoke('editor.setRange', point.node, point.offset); + range.normalize().select(); + }, + /** + * Insert a table. + * Note: adds <p><br></p> before/after the table if the table + * has nothing brefore/after it, so as to allow the carret to move there. + * + * @param {String} dim dimension of table (ex : "5x5") + */ + insertTable: function (dim) { + var dimension = dim.split('x'); + var table = this.createTable(dimension[0], dimension[1], this.options); + this.context.invoke('HelperPlugin.insertBlockNode', table); + var p; + if (!table.previousElementSibling) { + p = this.document.createElement('p'); + $(p).append(this.document.createElement('br')); + $(table).before(p); + } + if (!table.nextElementSibling) { + p = this.document.createElement('p'); + $(p).append(this.document.createElement('br')); + $(table).after(p); + } + var range = this.context.invoke('editor.setRange', $(table).find('td')[0], 0); + range.normalize().select(); + this.context.invoke('editor.saveRange'); + }, +}); + + +var TablePopover = Plugins.tablePopover.extend({ + events: _.defaults({ + 'summernote.scroll': '_onScroll', + }, Plugins.tablePopover.prototype.events), + + /** + * Update the table's popover and its position. + * + * @override + * @param {Node} target + * @returns {false|Node} the selected cell (on which to display the popover) + */ + update: function (target) { + if (!target || this.context.isDisabled()) { + return false; + } + var cell = dom.ancestor(target, dom.isCell); + if (!!cell && this.options.isEditableNode(cell)) { + var pos = $(cell).offset(); + var posContainer = $(this.options.container).offset(); + pos.left = pos.left - posContainer.left + 10; + pos.top = pos.top - posContainer.top + $(cell).outerHeight() - 4; + + this.lastPos = this.context.invoke('HelperPlugin.makePoint', target, $(target).offset()); + + this.$popover.css({ + display: 'block', + left: pos.left, + top: pos.top, + }); + } else { + this.hide(); + } + return cell; + }, + /** + * Update the target table and its popover. + * + * @private + */ + _onScroll: function () { + var range = this.context.invoke('editor.createRange'); + var target = dom.ancestor(range.sc, dom.isCell); + if (!target || target === this.editable) { + return; + } + if (this.lastPos && this.lastPos.target === target && $(target).offset()) { + var newTop = $(target).offset().top; + var movement = this.lastPos.offset.top - newTop; + if (movement && this.mousePosition) { + this.mousePosition.pageY -= movement; + } + } + return this.update(target); + }, +}); + + +registry.add('TablePlugin', TablePlugin); +registry.add('TablePopover', TablePopover); +registry.add('tablePopover', null); + +return TablePlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/text.js b/addons/web_editor/static/src/js/wysiwyg/plugin/text.js new file mode 100644 index 00000000000..d3ac908c507 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/text.js @@ -0,0 +1,799 @@ +odoo.define('web_editor.wysiwyg.plugin.text', function (require) { +'use strict'; + +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var dom = $.summernote.dom; +dom.isAnchor = function (node) { + return (node.tagName === 'A' || node.tagName === 'BUTTON' || $(node).hasClass('btn')) && + !$(node).hasClass('fa') && !$(node).hasClass('o_image'); +}; + + +var TextPlugin = AbstractPlugin.extend({ + events: { + 'summernote.paste': '_onPaste', + }, + + // See: https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#removeformat-candidate + formatTags: [ + 'abbr', + 'acronym', + 'b', + 'bdi', + 'bdo', + 'big', + 'blink', + 'cite', + 'code', + 'dfn', + 'em', + 'font', + 'i', + 'ins', + 'kbd', + 'mark', + 'nobr', + 'q', + 's', + 'samp', + 'small', + 'span', + 'strike', + 'strong', + 'sub', + 'sup', + 'tt', + 'u', + 'var', + ], + tab: '\u00A0\u00A0\u00A0\u00A0', + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Insert a Horizontal Rule element (hr). + */ + insertHR: function () { + var self = this; + var hr = this.document.createElement('hr'); + this.context.invoke('HelperPlugin.insertBlockNode', hr); + var point = this.context.invoke('HelperPlugin.makePoint', hr, 0); + point = dom.nextPointUntil(point, function (pt) { + return pt.node !== hr && !self.options.isUnbreakableNode(pt.node); + }) || point; + var range = $.summernote.range.create(point.node, point.offset); + range.select(); + }, + /** + * Insert a TAB (4 non-breakable spaces). + */ + insertTab: function () { + this.context.invoke('HelperPlugin.insertTextInline', this.tab); + }, + /** + * Paste nodes or their text content into the editor. + * + * @param {Node[]} nodes + * @param {Boolean} textOnly true to allow only dropping plain text + */ + pasteNodes: function (nodes, textOnly) { + if (!nodes.length) { + return; + } + nodes = textOnly ? this.document.createTextNode($(nodes).text()) : nodes; + nodes = this._mergeAdjacentULs(nodes); + + var point = this._getPastePoint(); + // Prevent pasting HTML within a link: + point = textOnly ? point : dom.nextPointUntil(point, this._isPointInAnchor.bind(this)); + + this._insertNodesAt(nodes, point); + + var start = nodes[nodes.length - 1]; + this.context.invoke('editor.setRange', start, dom.nodeLength(start)).normalize().select(); + }, + /** + * Prepare clipboard data for safe pasting into the editor. + * + * @see clipboardWhitelist + * @see clipboardBlacklist + * + * @param {DOMString} clipboardData + * @returns {Node[]} + */ + prepareClipboardData: function (clipboardData) { + var $clipboardData = this._removeIllegalClipboardElements($(clipboardData)); + + var $all = $clipboardData.find('*').addBack(); + $all.filter('table').addClass('table table-bordered'); + this._wrapTDContents($all.filter('td')); + this._fillEmptyBlocks($all); + this._removeIllegalClipboardAttributes($all); + $all.filter('a').removeClass(); + $all.filter('img').css('max-width', '100%'); + + return $clipboardData.toArray(); + }, + /** + * Format a 'format' block: change its tagName (eg: p -> h1). + * + * @param {string} tagName + * P, H1, H2, H3, H4, H5, H6, BLOCKQUOTE, PRE + */ + formatBlock: function (tagName) { + var self = this; + var r = this.context.invoke('editor.createRange'); + if ( + !r || + !this.$editable.has(r.sc).length || + !this.$editable.has(r.ec).length || + this.options.isUnbreakableNode(r.sc) + ) { + return; + } + var nodes = this.context.invoke('HelperPlugin.getSelectedNodes'); + nodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes); + if (!nodes.length) { + var node = this.context.invoke('editor.createRange').sc; + if (node.tagName === 'BR' || dom.isText(node)) { + node = node.parentNode; + } + nodes = [node]; + } + var changedNodes = []; + _.each(nodes, function (node) { + var newNode = self.document.createElement(tagName); + $(newNode).append($(node).contents()); + var attributes = $(node).prop("attributes"); + _.each(attributes, function (attr) { + $(newNode).attr(attr.name, attr.value); + }); + $(node).replaceWith(newNode); + changedNodes.push(newNode); + }); + + // Select all formatted nodes + if (changedNodes.length) { + var lastNode = changedNodes[changedNodes.length - 1]; + var startNode = changedNodes[0].firstChild || changedNodes[0]; + var endNode = lastNode.lastChild || lastNode; + var range = this.context.invoke('editor.setRange', startNode, 0, endNode, dom.nodeLength(endNode)); + range.select(); + } + }, + /** + * Change the paragraph alignment of a 'format' block. + * + * @param {string} style + * justifyLeft, justifyCenter, justifyRight, justifyFull + */ + formatBlockStyle: function (style) { + var self = this; + var nodes = this.context.invoke('HelperPlugin.getSelectedNodes'); + nodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes); + var align = style === 'justifyLeft' ? 'left' : + style === 'justifyCenter' ? 'center' : + style === 'justifyRight' ? 'right' : 'justify'; + _.each(nodes, function (node) { + if (dom.isText(node)) { + return; + } + var textAlign = self.window.getComputedStyle(node).textAlign; + if (align !== textAlign) { + if (align !== self.window.getComputedStyle(node.parentNode).textAlign) { + $(node).css('text-align', align); + } else { + $(node).css('text-align', ''); + } + } + }); + this.editable.normalize(); + }, + /** + * (Un-)format text: make it bold, italic, ... + * + * @param {string} tagName + * bold, italic, underline, strikethrough, superscript, subscript + */ + formatText: function (tagName) { + var self = this; + var tag = { + bold: 'B', + italic: 'I', + underline: 'U', + strikethrough: 'S', + superscript: 'SUP', + subscript: 'SUB', + } [tagName]; + if (!tag) { + throw new Error(tagName); + } + + var range = this.context.invoke('editor.createRange'); + if (!range || !this.$editable.has(range.sc).length || !this.$editable.has(range.ec).length) { + return; + } + if (range.isCollapsed()) { + var br; + if (range.sc.tagName === 'BR') { + br = range.sc; + } else if (range.sc.firstChild && range.sc.firstChild.tagName === 'BR') { + br = range.sc.firstChild; + } + if (br) { + var emptyText = this.document.createTextNode('\u200B'); + $(br).before(emptyText).remove(); + range = this.context.invoke('editor.setRange', emptyText, 0, emptyText, 1); + } else { + this.context.invoke('HelperPlugin.insertTextInline', '\u200B'); + range.eo += 1; + } + range.select(); + } + + var nodes = this.context.invoke('HelperPlugin.getSelectedNodes'); + var texts = this.context.invoke('HelperPlugin.filterLeafChildren', nodes); + var formatted = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes); + + var start = this.context.invoke('HelperPlugin.firstLeaf', nodes[0]); + var end = this.context.invoke('HelperPlugin.lastLeaf', nodes[nodes.length - 1]); + + function containsOnlySelectedText(node) { + return _.all(node.childNodes, function (n) { + return _.any(texts, function (t) { + return n === t && !(dom.isText(n) && n.textContent === ''); + }) && containsOnlySelectedText(n); + }); + } + + function containsAllSelectedText(node) { + return _.all(texts, function (t) { + return _.any(node.childNodes, function (n) { + return n === t && !(dom.isText(n) && n.textContent === '') || containsAllSelectedText(n); + }); + }); + } + + var nodeAlreadyStyled = []; + var toStyled = []; + var notStyled = _.filter(texts, function (text, index) { + if (toStyled.indexOf(text) !== -1 || nodeAlreadyStyled.indexOf(text) !== -1) { + return; + } + nodeAlreadyStyled.push(text); + + end = text; + + var styled = dom.ancestor(text, function (node) { + return node.tagName === tag; + }); + if (styled) { + if ( + !/^\u200B$/.test(text.textContent) && + containsAllSelectedText(styled) && + containsOnlySelectedText(styled) + ) { + // Unwrap all contents + nodes = $(styled).contents(); + $(styled).before(nodes).remove(); + nodeAlreadyStyled.push.apply(nodeAlreadyStyled, nodes); + end = _.last(nodeAlreadyStyled); + } else { + var options = { + isSkipPaddingBlankHTML: true, + isNotSplitEdgePoint: true, + }; + + if ( + nodeAlreadyStyled.indexOf(text.nextSibling) === -1 && + !dom.isRightEdgeOf(text, styled) + ) { + options.nextText = false; + var point = self.context.invoke('HelperPlugin.makePoint', text, dom.nodeLength(text)); + if (dom.isMedia(text)) { + point = dom.nextPoint(point); + } + var next = self.context.invoke('HelperPlugin.splitTree', styled, point, options); + nodeAlreadyStyled.push(next); + } + if ( + nodeAlreadyStyled.indexOf(text.previousSibling) === -1 && + !dom.isLeftEdgeOf(text, styled) + ) { + options.nextText = true; + var textPoint = self.context.invoke('HelperPlugin.makePoint', text, 0); + text = self.context.invoke('HelperPlugin.splitTree', styled, textPoint, options); + nodeAlreadyStyled.push(text); + if (index === 0) { + start = text; + } + end = text; + } + + var toRemove = dom.ancestor(text, function (n) { + return n.tagName === tag; + }); + if (toRemove) { + // Remove generated empty elements + if ( + toRemove.nextSibling && + self.context.invoke('HelperPlugin.isBlankNode', toRemove.nextSibling) + ) { + $(toRemove.nextSibling).remove(); + } + if ( + toRemove.previousSibling && + self.context.invoke('HelperPlugin.isBlankNode', toRemove.previousSibling) + ) { + $(toRemove.previousSibling).remove(); + } + + // Unwrap the element + nodes = $(toRemove).contents(); + $(toRemove).before(nodes).remove(); + nodeAlreadyStyled.push.apply(nodeAlreadyStyled, nodes); + end = _.last(nodeAlreadyStyled); + } + } + } + + if (dom.ancestor(text, function (node) { + return toStyled.indexOf(node) !== -1; + })) { + return; + } + + var node = text; + while ( + node && node.parentNode && + formatted.indexOf(node) === -1 && + formatted.indexOf(node.parentNode) === -1 + ) { + node = node.parentNode; + } + if (node !== text) { + if (containsAllSelectedText(node)) { + toStyled.push.apply(toStyled, texts); + node = text; + } else if (!containsOnlySelectedText(node)) { + + node = text; + } + } + + if (toStyled.indexOf(node) === -1) { + toStyled.push(node); + } + return !styled; + }); + + toStyled = _.uniq(toStyled); + + if (notStyled.length) { + nodes = []; + var toMerge = []; + _.each(toStyled, function (node) { + var next = true; + if (node.nextSibling && node.nextSibling.tagName === tag) { + $(node.nextSibling).prepend(node); + next = false; + } + if (node.previousSibling && node.previousSibling.tagName === tag) { + $(node.previousSibling).append(node); + } + if (node.parentNode && node.parentNode.tagName !== tag) { + var styled = self.document.createElement(tag); + if (node.tagName) { + $(styled).append(node.childNodes); + $(node).append(styled); + } else { + $(node).before(styled); + styled.appendChild(node); + } + } + // Add adjacent nodes with same tagName to list of nodes to merge + if ( + node.parentNode && node.parentNode[next ? 'nextSibling' : 'previousSibling'] && + node.parentNode.tagName === node.parentNode[next ? 'nextSibling' : 'previousSibling'].tagName + ) { + toMerge.push(next ? node.parentNode : node.parentNode.previousSibling); + } + }); + // Merge what needs merging + while (toMerge.length) { + this.context.invoke('HelperPlugin.deleteEdge', toMerge.pop(), 'next'); + } + } + + range = this.context.invoke('editor.setRange', start, 0, end, dom.nodeLength(end)); + + if (range.sc === range.ec && range.sc.textContent === '\u200B') { + range.so = range.eo = 1; + } + + range.select(); + this.editable.normalize(); + }, + /** + * Remove format on the current range. + * + * @see _isParentRemoveFormatCandidate + */ + removeFormat: function () { + this._selectCurrentIfCollapsed(); + var selectedText = this.context.invoke('HelperPlugin.getSelectedText'); + var selectedIcons = this.context.invoke('HelperPlugin.getSelectedNodes'); + selectedIcons = _.filter(selectedIcons, dom.isIcon); + if (!selectedText.length && !selectedIcons.length) { + return; + } + _.each(selectedIcons, this._removeIconFormat.bind(this)); + _.each(selectedText, this._removeTextFormat.bind(this)); + var startNode = selectedText[0]; + var endNode = selectedText[selectedText.length - 1]; + this.context.invoke('editor.setRange', startNode, 0, endNode, dom.nodeLength(endNode)).select(); + this.editable.normalize(); + this.context.invoke('editor.saveRange'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Remove the non-whitelisted or blacklisted + * top level elements from clipboard data. + * + * @see clipboardWhitelist + * @see clipboardBlacklist + * + * @private + * @param {JQuery} $clipboardData + * @returns {Object} {$clipboardData: JQuery, didRemoveNodes: Boolean} + */ + _cleanClipboardRoot: function ($clipboardData) { + var didRemoveNodes = false; + var whiteList = this._clipboardWhitelist(); + var blackList = this._clipboardBlacklist(); + var $fakeParent = $(this.document.createElement('div')); + _.each($clipboardData, function (node) { + var isWhitelisted = dom.isText(node) || $(node).filter(whiteList.join(',')).length; + var isBlacklisted = $(node).filter(blackList.join(',')).length; + if (!isWhitelisted || isBlacklisted) { + $fakeParent.append(node.childNodes); + didRemoveNodes = true; + } else { + $fakeParent.append(node); + } + }); + return { + $clipboardData: $fakeParent.contents(), + didRemoveNodes: didRemoveNodes, + }; + }, + /** + * Return a list of jQuery selectors for prohibited nodes on paste. + * + * @private + * @returns {String[]} + */ + _clipboardBlacklist: function () { + return ['.Apple-interchange-newline']; + }, + /** + * Return a list of jQuery selectors for exclusively authorized nodes on paste. + * + * @private + * @returns {String[]} + */ + _clipboardWhitelist: function () { + var listSels = ['ul', 'ol', 'li']; + var styleSels = ['i', 'b', 'u', 'em', 'strong']; + var tableSels = ['table', 'th', 'tbody', 'tr', 'td']; + var miscSels = ['img', 'br', 'a', '.fa']; + return this.options.styleTags.concat(listSels, styleSels, tableSels, miscSels); + }, + /** + * Return a list of attribute names that are exclusively authorized on paste. + * + * @private + * @returns {String[]} + */ + _clipboardWhitelistAttr: function () { + return ['class', 'href', 'src']; + }, + /** + * Fill up empty block elements with BR elements so the carret can enter them. + * + * @private + * @param {JQuery} $els + */ + _fillEmptyBlocks: function ($els) { + var self = this; + + $els.filter(function (i, n) { + return self.context.invoke('HelperPlugin.isNodeBlockType', n) && !n.childNodes; + }).append(this.document.createElement('br')); + }, + /** + * Get all non-whitelisted or blacklisted elements from clipboard data. + * + * @private + * @param {JQuery} $clipboardData + * @returns {JQuery} + */ + _filterIllegalClipboardElements: function ($clipboardData) { + return $clipboardData.find('*').addBack() + .not(this._clipboardWhitelist().join(',')) + .addBack(this._clipboardBlacklist().join(',')) + .filter(function () { + return !dom.isText(this); + }); + }, + /** + * Get a legal point to paste at, from the current range's start point. + * + * @private + * @returns {Object} {node: Node, offset: Number} + */ + _getPastePoint: function () { + var point = this.context.invoke('editor.createRange').getStartPoint(); + var offsetChild = point.node.childNodes[point.offset]; + point = offsetChild ? this.context.invoke('HelperPlugin.makePoint', offsetChild, 0) : point; + return dom.nextPointUntil(point, this._isPastePointLegal.bind(this)); + }, + /** + * Insert nodes at a point. Insert them inline if the first node is inline + * and pasting inline is legal at that point. + * + * @private + * @param {Node[]} nodes + * @param {Object} point {node: Node, offset: Number} + */ + _insertNodesAt: function (nodes, point) { + var canInsertInline = dom.isText(point.node) || point.node.tagName === 'BR' || dom.isMedia(point.node); + var $fakeParent = $(this.document.createElement('div')); + $fakeParent.append(nodes); + if (dom.isInline(nodes[0]) && canInsertInline) { + point.node = point.node.tagName ? point.node : point.node.splitText(point.offset); + $(point.node).before($fakeParent.contents()); + } else { + this.context.invoke('HelperPlugin.insertBlockNode', $fakeParent[0]); + } + $fakeParent.contents().unwrap(); + }, + /** + * Return true if the parent of the given node is a removeFormat candidate: + * - It is a removeFormat candidate as defined by W3C + * - It is contained within the editable area + * - It is not unbreakable + * + * @see formatTags the list of removeFormat candidates as defined by W3C + * + * @private + * @param {Node} node + */ + _isParentRemoveFormatCandidate: function (node) { + var parent = node.parentNode; + if (!parent) { + return false; + } + var isEditableOrAbove = parent && (parent === this.editable || $.contains(parent, this.editable)); + var isUnbreakable = parent && this.options.isUnbreakableNode(parent); + var isRemoveFormatCandidate = parent && parent.tagName && this.formatTags.indexOf(parent.tagName.toLowerCase()) !== -1; + return parent && !isEditableOrAbove && !isUnbreakable && isRemoveFormatCandidate; + }, + /** + * Return true if it's legal to paste nodes at the given point: + * if the point is not within a void node and the point is not unbreakable. + * + * @private + * @param {Object} point {node: Node, offset: Number} + * @returns {Boolean} + */ + _isPastePointLegal: function (point) { + var node = point.node; + var isWithinVoid = false; + if (node.parentNode) { + isWithinVoid = dom.isVoid(node.parentNode) || $(node.parentNode).filter('.fa').length; + } + return !isWithinVoid && !this.options.isUnbreakableNode(point.node); + }, + /** + * @private + * @param {Object} point {node: Node, offset: Number} + * @returns {Boolean} + */ + _isPointInAnchor: function (point) { + var ancestor = dom.ancestor(point.node, dom.isAnchor); + return !ancestor || ancestor === this.editable; + }, + /** + * Check a list of nodes and merges all adjacent ULs together: + * [ul, ul, p, ul, ul] will return [ul, p, ul], with the li's of + * nodes[1] and nodes[4] appended to nodes[0] and nodes[3]. + * + * @private + * @param {Node[]} nodes + * @return {Node[]} the remaining, merged nodes + */ + _mergeAdjacentULs: function (nodes) { + var res = []; + var prevNode; + _.each(nodes, function (node) { + prevNode = res[res.length - 1]; + if (prevNode && node.tagName === 'UL' && prevNode.tagName === 'UL') { + $(prevNode).append(node.childNodes); + } else { + res.push(node); + } + }); + return res; + }, + /** + * Remove an icon's format (colors, font size). + * + * @private + * @param {Node} icon + */ + _removeIconFormat: function (icon) { + $(icon).css({ + color: '', + backgroundColor: '', + fontSize: '', + }); + var reColorClasses = /(^|\s+)(bg|text)-\S*|/g; + icon.className = icon.className.replace(reColorClasses, '').trim(); + }, + /** + * Remove non-whitelisted attributes from clipboard. + * + * @private + * @param {JQuery} $els + */ + _removeIllegalClipboardAttributes: function ($els) { + var self = this; + $els.each(function () { + var $node = $(this); + _.each(_.pluck(this.attributes, 'name'), function (attribute) { + if (self._clipboardWhitelistAttr().indexOf(attribute) === -1) { + $node.removeAttr(attribute); + } + }); + }).removeClass('o_editable o_not_editable'); + }, + /** + * Remove non-whitelisted and blacklisted elements from clipboard data. + * + * @private + * @param {JQuery} $clipboardData + * @returns {JQuery} + */ + _removeIllegalClipboardElements: function ($clipboardData) { + var root = true; + $clipboardData = $clipboardData.not('meta').not('style').not('script'); + var $badNodes = this._filterIllegalClipboardElements($clipboardData); + + do { + if (root) { + root = false; + var cleanData = this._cleanClipboardRoot($clipboardData); + $clipboardData = cleanData.$clipboardData; + root = cleanData.didRemoveNodes; + } else { + this._removeNodesPreserveContents($badNodes); + } + + $badNodes = this._filterIllegalClipboardElements($clipboardData); + } while ($badNodes.length); + return $clipboardData; + }, + /** + * Remove nodes from the DOM while preserving their contents if any. + * + * @private + * @param {JQuery} $nodes + */ + _removeNodesPreserveContents: function ($nodes) { + var $contents = $nodes.contents(); + if ($contents.length) { + $contents.unwrap(); + } else { + $nodes.remove(); + } + }, + /** + * Remove a text node's format: remove its style parents (b, i, u, ...). + * + * @private + * @param {Node} textNode + */ + _removeTextFormat: function (textNode) { + var node = textNode; + while (this._isParentRemoveFormatCandidate(node)) { + this.context.invoke('HelperPlugin.splitAtNodeEnds', node); + $(node.parentNode).before(node).remove(); + } + }, + /** + * Select the whole current node if the range is collapsed + * + * @private + */ + _selectCurrentIfCollapsed: function () { + var range = this.context.invoke('editor.createRange'); + if (!range.isCollapsed()) { + return; + } + this.context.invoke('editor.setRange', range.sc, 0, range.sc, dom.nodeLength(range.sc)).select(); + this.context.invoke('editor.saveRange'); + }, + /** + * Prevent inline nodes directly in TDs by wrapping them in P elements. + * + * @private + * @param {JQuery} $tds + */ + _wrapTDContents: function ($tds) { + var self = this; + var $inlinesInTD = $tds.contents().filter(function () { + return !self.context.invoke('HelperPlugin.isNodeBlockType', this); + }); + var parentsOfInlinesInTD = []; + _.each($inlinesInTD, function (n) { + parentsOfInlinesInTD.push(self.context.invoke('HelperPlugin.firstBlockAncestor', n)); + }); + $($.unique(parentsOfInlinesInTD)).wrapInner(this.document.createElement('p')); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handle paste events to permit cleaning/sorting of the data before pasting. + * + * @private + * @param {SummernoteEvent} se + * @param {jQueryEvent} e + */ + _onPaste: function (se, e) { + se.preventDefault(); + se.stopImmediatePropagation(); + e.preventDefault(); + e.stopImmediatePropagation(); + + this.context.invoke('editor.beforeCommand'); + + // Clean up + var clipboardData = e.originalEvent.clipboardData.getData('text/html'); + if (clipboardData) { + clipboardData = this.prepareClipboardData(clipboardData); + } else { + clipboardData = e.originalEvent.clipboardData.getData('text/plain'); + // get that text as an array of text nodes separated by <br> where needed + var allNewlines = /\n/g; + clipboardData = $('<p>' + clipboardData.replace(allNewlines, '<br>') + '</p>').contents().toArray(); + } + + // Delete selection + this.context.invoke('HelperPlugin.deleteSelection'); + + // Insert the nodes + this.pasteNodes(clipboardData); + this.context.invoke('HelperPlugin.normalize'); + this.context.invoke('editor.saveRange'); + + this.context.invoke('editor.afterCommand'); + }, +}); + +registry.add('TextPlugin', TextPlugin); + +return TextPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js b/addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js new file mode 100644 index 00000000000..bc7a9f7abbf --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js @@ -0,0 +1,57 @@ +odoo.define('web_editor.wysiwyg.plugin.toolbar', function (require) { +'use strict'; + +var Plugins = require('web_editor.wysiwyg.plugins'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var dom = $.summernote.dom; + + +var ToolbarPlugin = Plugins.toolbar.extend({ + events: { + 'summernote.mouseup': 'update', + 'summernote.keyup': 'update', + 'summernote.change': 'update', + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + initialize: function () { + this._super(); + this.update(); + }, + /** + * Update the toolbar (enabled/disabled). + */ + update: function () { + var $btn = this.$toolbar.children().not('.note-history, .note-view').find('button'); + var target = this.context.invoke('editor.restoreTarget') || this.context.invoke('editor.createRange').sc; + + if (!target || !$.contains(this.editable, target) || !this.options.isEditableNode(target)) { + $btn.addClass('o_disabled'); + return; + } + + $btn.removeClass('o_disabled'); + + if (!target || !this.options.displayPopover(target)) { + $btn.addClass('o_disabled'); + } + + if (dom.ancestor(target, dom.isMedia)) { + this.$toolbar.children('.note-style, .note-font, .note-para, .note-table').addClass('o_disabled'); + + $btn.find('.fa-file-image-o').parent().removeClass('o_disabled'); + if (dom.ancestor(target, dom.isFont)) { + this.$toolbar.children('.note-color').removeClass('o_disabled'); + + } + } + }, +}); + +registry.add('toolbar', ToolbarPlugin); + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/transform.js b/addons/web_editor/static/src/js/wysiwyg/plugin/transform.js new file mode 100644 index 00000000000..f7fa5b860f2 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/transform.js @@ -0,0 +1,85 @@ +odoo.define('web_editor.wysiwyg.plugin.transform', function (require) { +'use strict'; + +var core = require('web.core'); +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var registry = require('web_editor.wysiwyg.plugin.registry'); +var wysiwygTranslation = require('web_editor.wysiwyg.translation'); +var wysiwygOptions = require('web_editor.wysiwyg.options'); + +var _t = core._t; + + +var TransformPlugin = AbstractPlugin.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Manages transformations on a media. + */ + transform: function () { + var $image = $(this.context.invoke('editor.restoreTarget')); + + if ($image.data('transfo-destroy')) { + $image.removeData('transfo-destroy'); + return; + } + + $image.transfo(); // see web_editor/static/lib/jQuery.transfo.js + + var mouseup = function () { + $('.note-popover button[data-event="transform"]').toggleClass('active', $image.is('[style*="transform"]')); + }; + $(document).on('mouseup', mouseup); + + var mousedown = this._wrapCommand(function (event) { + if (!$(event.target).closest('.transfo-container').length) { + $image.transfo('destroy'); + $(document).off('mousedown', mousedown).off('mouseup', mouseup); + } + if ($(event.target).closest('.note-popover').length) { + var transformStyles = self.context.invoke('HelperPlugin.getRegex', '', 'g', '[^;]*transform[\\w:]*;?'); + $image.data('transfo-destroy', true).attr('style', ($image.attr('style') || '').replace(transformStyles, '')); + } + }); + $(document).on('mousedown', mousedown); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds the transform buttons. + * + * @override + */ + _addButtons: function () { + var self = this; + this._super(); + + this.context.memo('button.transform', function () { + return self.context.invoke('buttons.button', { + contents: self.ui.icon(self.options.icons.transform), + tooltip: self.lang.image.transform, + click: self.context.createInvokeHandler('TransformPlugin.transform'), + }).render(); + }); + }, + +}); + + +_.extend(wysiwygOptions.icons, { + transform: 'fa fa-object-ungroup', +}); +_.extend(wysiwygTranslation.image, { + transform: _t('Transform the picture (click twice to reset transformation)'), +}); + +registry.add('TransformPlugin', TransformPlugin); + +return TransformPlugin; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js b/addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js new file mode 100644 index 00000000000..81a90d55413 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js @@ -0,0 +1,360 @@ +odoo.define('web_editor.wysiwyg.plugin.unbreakable', function (require) { +'use strict'; + +var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract'); +var registry = require('web_editor.wysiwyg.plugin.registry'); + +var dom = $.summernote.dom; + +//-------------------------------------------------------------------------- +// unbreakable node preventing editing +//-------------------------------------------------------------------------- + +var Unbreakable = AbstractPlugin.extend({ + events: { + 'wysiwyg.range .note-editable': '_onRange', + 'summernote.mouseup': '_onMouseUp', + 'summernote.keyup': '_onKeyup', + 'summernote.keydown': '_onKeydown', + // 'summernote.focusnode': '_onFocusnode', => add this event to summernote. + }, + + initialize: function () { + var self = this; + this._super.apply(this, arguments); + setTimeout(function () { + self.secureArea(); + self.context.invoke('HistoryPlugin.clear'); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Change the selection if it breaks an unbreakable node. + * + <unbreakable id="a"> + content_1 + <unbreakable id="b">content_2</unbreakable> + <allow id="c"> + content_3 + <unbreakable id="d">content_4</unbreakable> + <unbreakable id="e"> + content_5 + <allow id="f">content_6</allow> + content_7 + </unbreakable> + content_8 + </allow> + <unbreakable id="f">content_9</unbreakable> + <allow id="g"> + content_10 + <unbreakable id="h">content_11</unbreakable> + content_12 + </allow> + </unbreakable> + + START END RESIZE START RESIZE END + + content_1 content_1 content_3 content_3 (find the first allowed node) + content_1 content_2 content_3 content_3 + content_1 content_3 content_3 - + content_3 content_3 - - (nothing to do) + content_3 content_8 - - (can remove unbreakable node) + content_3 content_4 - content_3 + content_3 content_5 - #d (can select the entire unbreakable node) + content_5 content_8 content_6 content_6 + content_5 content_7 #e #e (select the entire unbreakable node) + content_6 content_8 - content_6 + content_7 content_8 - content_8 + content_9 content_12 content_10 - + * + * @returns {WrappedRange} + */ + secureRange: function () { + var self = this; + var range = this.context.invoke('editor.createRange'); + var isCollapsed = range.isCollapsed(); + var needReselect = false; + var startPoint = range.getStartPoint(); + var endPoint = range.getEndPoint(); + + // don't change the selection if the carret is just after a media in editable area + var prev; + if ( + isCollapsed && startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset] && + (prev = dom.prevPoint(startPoint)) && dom.isMedia(prev.node) && + this.options.isEditableNode(prev.node.parentNode) + ) { + return range; + } + + // move the start selection to an allowed node + var target = startPoint.node.childNodes[startPoint.offset] || startPoint.node; + if (startPoint.offset && startPoint.offset === dom.nodeLength(startPoint.node)) { + startPoint.node = this.context.invoke('HelperPlugin.lastLeaf', startPoint.node); + startPoint.offset = dom.nodeLength(startPoint.node); + } + if (!dom.isMedia(target) || !this.options.isEditableNode(target)) { + var afterEnd = false; + startPoint = dom.nextPointUntil(startPoint, function (point) { + if (point.node === endPoint.node && point.offset === endPoint.offset) { + afterEnd = true; + } + return self.options.isEditableNode(point.node) && dom.isVisiblePoint(point) || !point.node; + }); + if (!startPoint || !startPoint.node) { // no allowed node, search the other way + afterEnd = false; + startPoint = dom.prevPointUntil(range.getStartPoint(), function (point) { + return self.options.isEditableNode(point.node) && dom.isVisiblePoint(point) || !point.node; + }); + } + if (startPoint && !startPoint.node) { + startPoint = null; + } + if (afterEnd) { + isCollapsed = true; + } + } + + if (startPoint && (startPoint.node !== range.sc || startPoint.offset !== range.so)) { + needReselect = true; + range.sc = startPoint.node; + range.so = startPoint.offset; + if (isCollapsed) { + range.ec = range.sc; + range.eo = range.so; + } + } + + if (startPoint && !isCollapsed) { // mouse selection or key selection with shiftKey + var point = endPoint; + endPoint = false; + + // if the start point was moved after the end point + var toCollapse = !dom.prevPointUntil(point, function (point) { + return point.node === range.sc && point.offset === range.so; + }); + + if (!toCollapse) { + // find the first allowed ancestor + var commonUnbreakableParent = dom.ancestor(range.sc, function (node) { + return !dom.isMedia(node) && self.options.isUnbreakableNode(node); + }); + if (!commonUnbreakableParent) { + commonUnbreakableParent = this.editable; + } + + var lastCheckedNode; + if (point.offset === dom.nodeLength(point.node)) { + point = dom.nextPoint(point); + } + + // move the end selection to an allowed node in the first allowed ancestor + endPoint = dom.prevPointUntil(point, function (point) { + if (point.node === range.sc && point.offset === range.so) { + return true; + } + if (lastCheckedNode === point.node) { + return false; + } + + // select the entirety of the unbreakable node + if ( + point.node.tagName && point.offset && + $.contains(commonUnbreakableParent, point.node) && + self.options.isUnbreakableNode(point.node) + ) { + return true; + } + + var unbreakableParent = dom.ancestor(point.node, function (node) { + return !dom.isMedia(node) && self.options.isUnbreakableNode(node); + }); + if (!unbreakableParent) { + unbreakableParent = self.editable; + } + + if (commonUnbreakableParent !== unbreakableParent) { + lastCheckedNode = point.node; + return false; + } + lastCheckedNode = point.node; + if (!self.options.isEditableNode(point.node)) { + return false; + } + if ( + (/\S|\u200B|\u00A0/.test(point.node.textContent) || + dom.isMedia(point.node)) && + dom.isVisiblePoint(point) + ) { + return true; + } + if (dom.isText(point.node)) { + lastCheckedNode = point.node; + } + return false; + }); + } + + if (!endPoint) { + endPoint = range.getStartPoint(); + } + + if (endPoint.node !== range.ec || endPoint.offset !== range.eo) { + needReselect = true; + range.ec = endPoint.node; + range.eo = endPoint.offset; + } + } + + if (needReselect) { + range = range.select(); + this.context.invoke('editor.saveRange'); + } + return range; + }, + /** + * Apply contentEditable false on all media. + * + * @param {DOM} [node] default is editable area + */ + secureArea: function (node) { + this.$editable.find('o_not_editable').attr('contentEditable', 'false'); + + var medias = (function findMedia(node) { + var medias = []; + if (node.tagName !== 'IMG' && dom.isMedia(node)) { + medias.push(node); + } else { + $(node.childNodes).each(function () { + if (this.tagName) { + medias.push.apply(medias, findMedia(this)); + } + }); + } + return medias; + })(node || this.editable); + $(medias).addClass('o_fake_not_editable').attr('contentEditable', 'false'); + + $(medias).each(function () { + if (dom.isVideo(this) && !$(this).children('.o_fake_editable').length) { + // allow char insertion + $(this).prepend('<div class="o_fake_editable o_wysiwyg_to_remove" style="position: absolute;" contentEditable="true"/>'); + $(this).append('<div class="o_fake_editable o_wysiwyg_to_remove" style="position: absolute;" contentEditable="true"/>'); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Trigger a focusnode event when the focus enters another node. + * + * @param {DOM} node + */ + _focusNode: function (node) { + if (!node.tagName) { + node = node.parentNode; + } + if (this._focusedNode !== node) { + this._focusedNode = node; + this.context.triggerEvent('focusnode', node); + } + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * Method called on wysiwyg.range event on the editable: secures the range, refocuses. + */ + _onRange: function () { + var range = this.secureRange(); + this._focusNode(range.sc); + }, + /** + * Method called on mouseup event: secures the range, refocuses. + */ + _onMouseUp: function () { + var range = this.secureRange(); + this._focusNode(range.ec); + }, + /** + * Method called on keydown event: prevents changes to unbreakable nodes. + * + * @param {SummernoteEvent} se + * @param {jQueryEvent} e + */ + _onKeydown: function (se, e) { + if (!e.key || (e.key.length !== 1 && e.keyCode !== 8 && e.keyCode !== 46)) { + return; + } + var range; + // for test tour, to trigger Keydown on target (instead of use Wysiwyg.setRange) + if ( + e.target !== this._focusedNode && + (this.editable === e.target || $.contains(this.editable, e.target)) + ) { + range = this.context.invoke('editor.createRange'); + if (!$.contains(e.target, range.sc) && !$.contains(e.target, range.ec)) { + range = this.context.invoke('editor.setRange', e.target, 0); + range = range.normalize().select(); + this.context.invoke('editor.saveRange'); + this._focusNode(range.ec); + } + } + + // rerange to prevent some edition. + // eg: if the user select with arraw and shifKey and keypress an other char + range = this.secureRange(); + var target = range.getStartPoint(); + + if (e.keyCode === 8) { // backspace + if (!target || this.options.isUnbreakableNode(target.node)) { + e.preventDefault(); + } + } else if (e.keyCode === 46) { // delete + target = dom.nextPointUntil(dom.nextPoint(target), dom.isVisiblePoint); + if (!target || this.options.isUnbreakableNode(target.node)) { + e.preventDefault(); + } + } + }, + /** + * Method called on keyup event: prevents selection of unbreakable nodes. + * + * @param {SummernoteEvent} se + * @param {jQueryEvent} se + */ + _onKeyup: function (se, e) { + if (e.keyCode < 37 || e.keyCode > 40) { + return; + } + var range; + if (e.keyCode === 37) { // left + range = this.secureRange(); + this._focusNode(range.sc); + } else if (e.keyCode === 39) { // right + range = this.secureRange(); + this._focusNode(range.ec); + } else if (e.keyCode === 38) { // up + range = this.secureRange(); + this._focusNode(range.sc); + } else { // down + range = this.secureRange(); + this._focusNode(range.ec); + } + }, +}); + +registry.add('UnbreakablePlugin', Unbreakable); + +return Unbreakable; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin_registry.js b/addons/web_editor/static/src/js/wysiwyg/plugin_registry.js new file mode 100644 index 00000000000..bdd59cb41d2 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/plugin_registry.js @@ -0,0 +1,43 @@ +odoo.define('web_editor.wysiwyg.plugin.registry', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var core = require('web.core'); +var Registry = require('web.Registry'); + + +var WysiwygRegistry = Registry.extend({ + init: function () { + this._super(); + this._jobs = []; + this._xmlDependencies = []; + this._def = $.when(); + }, + start: function (wysiwyg) { + var defs = [this._def]; + var fn; + while ((fn = this._jobs.shift())) { + defs.push(fn(wysiwyg)); + } + var xmlPath; + while ((xmlPath = this._xmlDependencies.shift())) { + defs.push(ajax.loadXML(xmlPath, core.qweb)); + } + if (defs.length !== 1) { + this._def = $.when.apply($, defs); + } + return this._def.state() === 'resolved' ? $.when() : this._def; + }, + addJob: function (job) { + this._jobs.push(job); + }, + addXmlDependency: function (xmlDependency) { + this._xmlDependencies.push(xmlDependency); + }, + plugins: function () { + return _.clone(this.map); + }, +}); + +return new WysiwygRegistry(); +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/translation.js b/addons/web_editor/static/src/js/wysiwyg/translation.js new file mode 100644 index 00000000000..e2f31e42293 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/translation.js @@ -0,0 +1,161 @@ +odoo.define('web_editor.wysiwyg.translation', function (require) { +'use strict'; + +var core = require('web.core'); +var _t = core._t; + +$.summernote.lang.odoo = { + font: { + bold: _t('Bold'), + italic: _t('Italic'), + underline: _t('Underline'), + clear: _t('Remove Font Style'), + height: _t('Line Height'), + name: _t('Font Family'), + strikethrough: _t('Strikethrough'), + subscript: _t('Subscript'), + superscript: _t('Superscript'), + size: _t('Font Size') + }, + image: { + image: _t('Picture'), + insert: _t('Insert Image'), + resizeFull: _t('Resize Full'), + resizeHalf: _t('Resize Half'), + resizeQuarter: _t('Resize Quarter'), + floatLeft: _t('Float Left'), + floatRight: _t('Float Right'), + floatNone: _t('Float None'), + shapeRounded: _t('Shape: Rounded'), + shapeCircle: _t('Shape: Circle'), + shapeThumbnail: _t('Shape: Thumbnail'), + shapeNone: _t('Shape: None'), + dragImageHere: _t('Drag image or text here'), + dropImage: _t('Drop image or Text'), + selectFromFiles: _t('Select from files'), + maximumFileSize: _t('Maximum file size'), + maximumFileSizeError: _t('Maximum file size exceeded.'), + url: _t('Image URL'), + remove: _t('Remove Image'), + original: _t('Original') + }, + video: { + video: _t('Video'), + videoLink: _t('Video Link'), + insert: _t('Insert Video'), + url: _t('Video URL'), + providers: _t('(YouTube, Vimeo, Vine, Instagram, DailyMotion or Youku)') + }, + link: { + link: _t('Link'), + insert: _t('Insert Link'), + unlink: _t('Unlink'), + edit: _t('Edit'), + textToDisplay: _t('Text to display'), + url: _t('To what URL should this link go?'), + openInNewWindow: _t('Open in new window') + }, + table: { + table: _t('Table'), + addRowAbove: _t('Add row above'), + addRowBelow: _t('Add row below'), + addColLeft: _t('Add column left'), + addColRight: _t('Add column right'), + delRow: _t('Delete row'), + delCol: _t('Delete column'), + delTable: _t('Delete table') + }, + hr: { + insert: _t('Insert Horizontal Rule') + }, + style: { + style: _t('Style'), + p: _t('Normal'), + blockquote: _t('Quote'), + pre: _t('Code'), + h1: _t('Header 1'), + h2: _t('Header 2'), + h3: _t('Header 3'), + h4: _t('Header 4'), + h5: _t('Header 5'), + h6: _t('Header 6') + }, + lists: { + unordered: _t('Unordered list'), + ordered: _t('Ordered list') + }, + options: { + help: _t('Help'), + fullscreen: _t('Full Screen'), + codeview: _t('Code View') + }, + paragraph: { + paragraph: _t('Paragraph'), + outdent: _t('Outdent'), + indent: _t('Indent'), + left: _t('Align left'), + center: _t('Align center'), + right: _t('Align right'), + justify: _t('Justify full') + }, + color: { + recent: _t('Recent Color'), + more: _t('More Color'), + background: _t('Background Color'), + foreground: _t('Foreground Color'), + transparent: _t('Transparent'), + setTransparent: _t('Set transparent'), + reset: _t('Reset'), + resetToDefault: _t('Reset to default') + }, + shortcut: { + shortcuts: _t('Keyboard shortcuts'), + close: _t('Close'), + textFormatting: _t('Text formatting'), + action: _t('Action'), + paragraphFormatting: _t('Paragraph formatting'), + documentStyle: _t('Document Style'), + extraKeys: _t('Extra keys') + }, + help: { + insertParagraph: _t('Insert Paragraph'), + undo: _t('Undoes the last command'), + redo: _t('Redoes the last command'), + tab: _t('Tab'), + untab: _t('Outdent (when at the start of a line)'), + bold: _t('Set a bold style'), + italic: _t('Set a italic style'), + underline: _t('Set a underline style'), + strikethrough: _t('Set a strikethrough style'), + removeFormat: _t('Clean a style'), + justifyLeft: _t('Set left align'), + justifyCenter: _t('Set center align'), + justifyRight: _t('Set right align'), + justifyFull: _t('Set full align'), + insertUnorderedList: _t('Toggle unordered list'), + insertOrderedList: _t('Toggle ordered list'), + outdent: _t('Outdent current paragraph'), + indent: _t('Indent current paragraph'), + formatPara: _t('Change current block\'s format as a paragraph(P tag)'), + formatH1: _t('Change current block\'s format as H1'), + formatH2: _t('Change current block\'s format as H2'), + formatH3: _t('Change current block\'s format as H3'), + formatH4: _t('Change current block\'s format as H4'), + formatH5: _t('Change current block\'s format as H5'), + formatH6: _t('Change current block\'s format as H6'), + insertHorizontalRule: _t('Insert horizontal rule'), + 'linkDialog.show': _t('Show Link Dialog') + }, + history: { + undo: _t('Undo'), + redo: _t('Redo') + }, + specialChar: { + specialChar: _t('SPECIAL CHARACTERS'), + select: _t('Select Special characters') + } +}; + +return $.summernote.lang.odoo; + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js new file mode 100644 index 00000000000..63d90fc3f52 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js @@ -0,0 +1,64 @@ +odoo.define('wysiwyg.widgets.AltDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('wysiwyg.widgets.Dialog'); + +var _t = core._t; + +/** + * Let users change the alt & title of a media. + */ +var AltDialog = Dialog.extend({ + template: 'wysiwyg.widgets.alt', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web_editor/static/src/xml/editor.xml'] + ), + + /** + * @constructor + */ + init: function (parent, options, media) { + options = options || {}; + this._super(parent, _.extend({}, { + title: _t("Change media description and tooltip") + }, options)); + + this.trigger_up('getRecordInfo', { + recordInfo: options, + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + + this.media = media; + var allEscQuots = /"/g; + this.alt = ($(this.media).attr('alt') || "").replace(allEscQuots, '"'); + var title = $(this.media).attr('title') || $(this.media).data('original-title') || ""; + this.tag_title = (title).replace(allEscQuots, '"'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var alt = this.$('#alt').val(); + var title = this.$('#title').val(); + var allNonEscQuots = /"/g; + $(this.media).attr('alt', alt ? alt.replace(allNonEscQuots, """) : null) + .attr('title', title ? title.replace(allNonEscQuots, """) : null); + + this.trigger('saved', { + media: this.media, + }); + return this._super.apply(this, arguments); + }, +}); + + +return AltDialog; +}); diff --git a/addons/web/static/src/js/widgets/colorpicker.js b/addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js index e8ed888e1af..3538b1ad874 100644 --- a/addons/web/static/src/js/widgets/colorpicker.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js @@ -1,4 +1,4 @@ -odoo.define('web.colorpicker', function (require) { +odoo.define('wysiwyg.widgets.ColorpickerDialog', function (require) { 'use strict'; var core = require('web.core'); @@ -7,11 +7,11 @@ var Dialog = require('web.Dialog'); var _t = core._t; -var Colorpicker = Dialog.extend({ +var ColorpickerDialog = Dialog.extend({ xmlDependencies: (Dialog.prototype.xmlDependencies || []) - .concat(['/web/static/src/xml/colorpicker.xml']), + .concat(['/web_editor/static/src/xml/wysiwyg_colorpicker.xml']), - template: 'web.colorpicker', + template: 'wysiwyg.widgets.ColorpickerDialog', events: _.extend({}, Dialog.prototype.events || {}, { 'mousedown .o_color_pick_area': '_onMouseDownPicker', 'mousedown .o_color_slider': '_onMouseDownSlider', @@ -37,6 +37,13 @@ var Colorpicker = Dialog.extend({ ], }, options)); + this.trigger_up('getRecordInfo', { + recordInfo: options, + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + this.pickerFlag = false; this.sliderFlag = false; this.opacitySliderFlag = false; @@ -69,15 +76,9 @@ var Colorpicker = Dialog.extend({ this.$opacitySliderPointer = this.$('.o_opacity_pointer'); var defaultColor = this.options.defaultColor || '#FF0000'; - var rgba = defaultColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); + var rgba = ColorpickerDialog.convertColorToRgba(defaultColor); if (rgba) { - if (rgba[4] === undefined) { - rgba[4] = 1; - } - this._updateRgba(parseInt(rgba[1]), parseInt(rgba[2]), parseInt(rgba[3]), Math.round(parseFloat(rgba[4] * 100))); - } else { - this._updateHex(defaultColor); - this._updateOpacity(100); + this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); } this.opened().then(this._updateUI.bind(this)); @@ -140,14 +141,14 @@ var Colorpicker = Dialog.extend({ * @param {string} hex - hexadecimal code */ _updateHex: function (hex) { - var rgb = Colorpicker.prototype.convertHexToRgb(hex); + var rgb = ColorpickerDialog.convertHexToRgba(hex); if (!rgb) { return; } _.extend(this.colorComponents, {hex: hex}, rgb, - Colorpicker.prototype.convertRgbToHsl(rgb.red, rgb.green, rgb.blue) + ColorpickerDialog.convertRgbToHsl(rgb.red, rgb.green, rgb.blue) ); this._updateCssColor(); }, @@ -161,7 +162,7 @@ var Colorpicker = Dialog.extend({ * @param {integer} [a] */ _updateRgba: function (r, g, b, a) { - var hex = Colorpicker.prototype.convertRgbToHex(r, g, b); + var hex = ColorpickerDialog.convertRgbToHex(r, g, b); if (!hex) { return; } @@ -169,7 +170,7 @@ var Colorpicker = Dialog.extend({ {red: r, green: g, blue: b}, a === undefined ? {} : {opacity: a}, hex, - Colorpicker.prototype.convertRgbToHsl(r, g, b) + ColorpickerDialog.convertRgbToHsl(r, g, b) ); this._updateCssColor(); }, @@ -182,14 +183,14 @@ var Colorpicker = Dialog.extend({ * @param {integer} l */ _updateHsl: function (h, s, l) { - var rgb = Colorpicker.prototype.convertHslToRgb(h, s, l); + var rgb = ColorpickerDialog.convertHslToRgb(h, s, l); if (!rgb) { return; } _.extend(this.colorComponents, {hue: h, saturation: s, lightness: l}, rgb, - Colorpicker.prototype.convertRgbToHex(rgb.red, rgb.green, rgb.blue) + ColorpickerDialog.convertRgbToHex(rgb.red, rgb.green, rgb.blue) ); this._updateCssColor(); }, @@ -214,17 +215,8 @@ var Colorpicker = Dialog.extend({ * @private */ _updateCssColor: function () { - var cssColor = this.colorComponents.hex; - if (this.colorComponents.opacity !== 100) { - cssColor = _.str.sprintf('rgba(%s, %s, %s, %s)', - this.colorComponents.red, - this.colorComponents.green, - this.colorComponents.blue, - this.colorComponents.opacity / 100 - ); - } _.extend(this.colorComponents, - {cssColor: cssColor} + {cssColor: ColorpickerDialog.formatColor(this.colorComponents)} ); }, @@ -358,140 +350,214 @@ var Colorpicker = Dialog.extend({ _onFinalPick: function () { this.trigger_up('colorpicker:saved', this.colorComponents); }, +}); - //-------------------------------------------------------------------------- - // Static - //-------------------------------------------------------------------------- - - /** - * Converts Hexadecimal code to RGB. - * - * @static - * @param {string} hex - hexadecimal code - * @returns {Object|false} contains red, green and blue - */ - convertHexToRgb: function (hex) { - if (!/^#[0-9A-F]{6}$/i.test(hex)) { - return false; +//-------------------------------------------------------------------------- +// Static +//-------------------------------------------------------------------------- + +/** + * Converts any color to string RGBA. + * + * @static + * @param {Object|string} color + * @returns {string} rgba (red, green, blue, opacity) + */ +ColorpickerDialog.formatColor = function (color) { + if (typeof color === 'string') { + color = ColorpickerDialog.convertColorToRgba(color); + } + if (!color) { + return ''; + } + if (color.opacity === 100) { + return ColorpickerDialog.convertRgbToHex(color.red, color.green, color.blue).hex; + } + return _.str.sprintf('rgba(%s, %s, %s, %s)', + color.red, + color.green, + color.blue, + color.opacity / 100 + ); +}; +/** + * Converts any color to code to RGBA. + * + * @static + * @param {string} color + * @returns {Object|false} contains red, green, blue, opacity + */ +ColorpickerDialog.convertColorToRgba = function (color) { + var rgba = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); + if (rgba) { + if (rgba[4] === undefined) { + rgba[4] = 100; } - return { - red: parseInt(hex.substr(1, 2), 16), - green: parseInt(hex.substr(3, 2), 16), - blue: parseInt(hex.substr(5, 2), 16), + red: parseInt(rgba[1]), + green: parseInt(rgba[2]), + blue: parseInt(rgba[3]), + opacity: parseInt(rgba[4]), }; - }, - /** - * Converts RGB color to HSL. - * - * @static - * @param {integer} r - * @param {integer} g - * @param {integer} b - * @returns {Object|false} contains hue, saturation and lightness - */ - convertRgbToHsl: function (r, g, b) { - if (typeof(r) !== 'number' || isNaN(r) || r < 0 || r > 255 || - typeof(g) !== 'number' || isNaN(g) || g < 0 || g > 255 || - typeof(b) !== 'number' || isNaN(b) || b < 0 || b > 255) { - return false; + } else { + return ColorpickerDialog.convertHexToRgba(color); + } +}; +/** + * Converts Hexadecimal code to RGB. + * + * @static + * @param {string} hex - hexadecimal code + * @returns {Object|false} contains red, green and blue + */ +ColorpickerDialog.convertHexToRgba = function (hex) { + if (!/^#([0-9A-F]{6}|[0-9A-F]{8})$/i.test(hex)) { + return false; + } + + return { + red: parseInt(hex.substr(1, 2), 16), + green: parseInt(hex.substr(3, 2), 16), + blue: parseInt(hex.substr(5, 2), 16), + opacity: hex.length === 9 ? parseInt(hex.substr(7, 2), 16) : 100, + }; +}; +/** + * Converts RGB color to HSL. + * + * @static + * @param {integer} r + * @param {integer} g + * @param {integer} b + * @returns {Object|false} contains hue, saturation and lightness + */ +ColorpickerDialog.convertRgbToHsl = function (r, g, b) { + if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 || + typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 || + typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { + return false; + } + + var red = r / 255; + var green = g / 255; + var blue = b / 255; + var maxColor = Math.max(red, green, blue); + var minColor = Math.min(red, green, blue); + var delta = maxColor - minColor; + var hue = 0; + var saturation = 0; + var lightness = (maxColor + minColor) / 2; + if (delta) { + if (maxColor === red) { + hue = (green - blue) / delta; } - - var red = r / 255; - var green = g / 255; - var blue = b / 255; - var maxColor = Math.max(red, green, blue); - var minColor = Math.min(red, green, blue); - var delta = maxColor - minColor; - var hue = 0; - var saturation = 0; - var lightness = (maxColor + minColor) / 2; - if (delta) { - if (maxColor === red) { - hue = (green - blue) / delta; - } - if (maxColor === green) { - hue = 2 + (blue - red) / delta; - } - if (maxColor === blue) { - hue = 4 + (red - green) / delta; - } - if (maxColor) { - saturation = delta / (1 - Math.abs(2 * lightness - 1)); - } + if (maxColor === green) { + hue = 2 + (blue - red) / delta; } - hue = 60 * hue | 0; - return { - hue: hue < 0 ? hue += 360 : hue, - saturation: (saturation * 100) | 0, - lightness: (lightness * 100) | 0, - }; - }, - /** - * Converts HSL color to RGB. - * - * @static - * @param {integer} h - * @param {integer} s - * @param {integer} l - * @returns {Object|false} contains red, green and blue - */ - convertHslToRgb: function (h, s, l) { - if (typeof(h) !== 'number' || isNaN(h) || h < 0 || h > 360 || - typeof(s) !== 'number' || isNaN(s) || s < 0 || s > 100 || - typeof(l) !== 'number' || isNaN(l) || l < 0 || l > 100) { - return false; + if (maxColor === blue) { + hue = 4 + (red - green) / delta; } - var huePrime = h / 60; - var saturation = s / 100; - var lightness = l / 100; - var chroma = saturation * (1 - Math.abs(2 * lightness - 1)); - var secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1)); - var lightnessAdjustment = lightness - chroma/2; - var precision = 255; - chroma = (chroma + lightnessAdjustment) * precision | 0; - secondComponent = (secondComponent + lightnessAdjustment) * precision | 0; - lightnessAdjustment = lightnessAdjustment * precision | 0; - if (huePrime >= 0 && huePrime < 1) { - return {red: chroma, green: secondComponent, blue: lightnessAdjustment}; + if (maxColor) { + saturation = delta / (1 - Math.abs(2 * lightness - 1)); } - if (huePrime >= 1 && huePrime < 2) { - return {red: secondComponent, green: chroma, blue: lightnessAdjustment}; - } - if (huePrime >= 2 && huePrime < 3) { - return {red: lightnessAdjustment, green: chroma, blue: secondComponent}; - } - if (huePrime >= 3 && huePrime < 4) { - return {red: lightnessAdjustment, green: secondComponent, blue: chroma}; - } - if (huePrime >= 4 && huePrime < 5) { - return {red: secondComponent, green: lightnessAdjustment, blue: chroma}; - } - if (huePrime >= 5 && huePrime <= 6) { - return {red: chroma, green: lightnessAdjustment, blue: secondComponent}; - } - }, - /** - * Converts RGB color to Hexadecimal code. - * - * @static - * @param {integer} r - * @param {integer} g - * @param {integer} b - * @returns {Object|false} contains hexadecimal code - */ - convertRgbToHex: function (r, g, b) { - if (typeof(r) !== 'number' || isNaN(r) || r < 0 || r > 255 || - typeof(g) !== 'number' || isNaN(g) || g < 0 || g > 255 || - typeof(b) !== 'number' || isNaN(b) || b < 0 || b > 255) { - return false; - } - var red = r < 16 ? '0' + r.toString(16) : r.toString(16); - var green = g < 16 ? '0' + g.toString(16) : g.toString(16); - var blue = b < 16 ? '0' + b.toString(16) : b.toString(16); - return {hex: _.str.sprintf('#%s%s%s', red, green, blue)}; - }, -}); - -return Colorpicker; + } + hue = 60 * hue | 0; + return { + hue: hue < 0 ? hue += 360 : hue, + saturation: (saturation * 100) | 0, + lightness: (lightness * 100) | 0, + }; +}; +/** + * Converts HSL color to RGB. + * + * @static + * @param {integer} h + * @param {integer} s + * @param {integer} l + * @returns {Object|false} contains red, green and blue + */ +ColorpickerDialog.convertHslToRgb = function (h, s, l) { + if (typeof (h) !== 'number' || isNaN(h) || h < 0 || h > 360 || + typeof (s) !== 'number' || isNaN(s) || s < 0 || s > 100 || + typeof (l) !== 'number' || isNaN(l) || l < 0 || l > 100) { + return false; + } + var huePrime = h / 60; + var saturation = s / 100; + var lightness = l / 100; + var chroma = saturation * (1 - Math.abs(2 * lightness - 1)); + var secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1)); + var lightnessAdjustment = lightness - chroma / 2; + var precision = 255; + chroma = (chroma + lightnessAdjustment) * precision | 0; + secondComponent = (secondComponent + lightnessAdjustment) * precision | 0; + lightnessAdjustment = lightnessAdjustment * precision | 0; + if (huePrime >= 0 && huePrime < 1) { + return { + red: chroma, + green: secondComponent, + blue: lightnessAdjustment, + }; + } + if (huePrime >= 1 && huePrime < 2) { + return { + red: secondComponent, + green: chroma, + blue: lightnessAdjustment, + }; + } + if (huePrime >= 2 && huePrime < 3) { + return { + red: lightnessAdjustment, + green: chroma, + blue: secondComponent, + }; + } + if (huePrime >= 3 && huePrime < 4) { + return { + red: lightnessAdjustment, + green: secondComponent, + blue: chroma, + }; + } + if (huePrime >= 4 && huePrime < 5) { + return { + red: secondComponent, + green: lightnessAdjustment, + blue: chroma, + }; + } + if (huePrime >= 5 && huePrime <= 6) { + return { + red: chroma, + green: lightnessAdjustment, + blue: secondComponent, + }; + } +}; +/** + * Converts RGB color to Hexadecimal code. + * + * @static + * @param {integer} r + * @param {integer} g + * @param {integer} b + * @returns {Object|false} contains hexadecimal code + */ +ColorpickerDialog.convertRgbToHex = function (r, g, b) { + if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 || + typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 || + typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { + return false; + } + var red = r < 16 ? '0' + r.toString(16) : r.toString(16); + var green = g < 16 ? '0' + g.toString(16) : g.toString(16); + var blue = b < 16 ? '0' + b.toString(16) : b.toString(16); + return { + hex: _.str.sprintf('#%s%s%s', red, green, blue) + }; +}; + +return ColorpickerDialog; }); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js new file mode 100644 index 00000000000..77bcd5578b3 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js @@ -0,0 +1,218 @@ +odoo.define('wysiwyg.widgets.CropImageDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('wysiwyg.widgets.Dialog'); + +var _t = core._t; + +/** + * CropImageDialog widget. Let users crop an image. + */ +var CropImageDialog = Dialog.extend({ + template: 'wysiwyg.widgets.crop_image', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web_editor/static/src/xml/editor.xml'] + ), + jsLibs: [ + '/web_editor/static/lib/cropper/js/cropper.js', + ], + cssLibs: [ + '/web_editor/static/lib/cropper/css/cropper.css', + ], + events: _.extend({}, Dialog.prototype.events, { + 'click .o_crop_options [data-event]': '_onCropOptionClick', + }), + + /** + * @constructor + */ + init: function (parent, options, media) { + var self = this; + this.media = media; + this.$media = $(this.media); + var src = this.$media.attr('src').split('?')[0]; + this.aspectRatioList = [ + [_t("Free"), '0/0', 0], + ["16:9", '16/9', 16 / 9], + ["4:3", '4/3', 4 / 3], + ["1:1", '1/1', 1], + ["2:3", '2/3', 2 / 3], + ]; + this.imageData = { + imageSrc: src, + originalSrc: this.$media.data('crop:originalSrc') || src, // the original src for cropped DB images will be fetched later + mimetype: this.$media.data('crop:mimetype') || (_.str.endsWith(src, '.png') ? 'image/png' : 'image/jpeg'), // the mimetype for DB images will be fetched later + aspectRatio: this.$media.data('aspectRatio') || this.aspectRatioList[0][1], + isExternalImage: src.substr(0, 5) !== 'data:' && src[0] !== '/' && src.indexOf(window.location.host) < 0, + }; + this.options = _.extend({ + title: _t("Crop Image"), + buttons: this.imageData.isExternalImage ? [{ + text: _t("Close"), + close: true, + }] : [{ + text: _t("Save"), + classes: 'btn-primary', + click: this.save, + }, { + text: _t("Discard"), + close: true, + }], + }, options || {}); + this._super(parent, this.options); + this.trigger_up('getRecordInfo', _.extend(this.options, { + callback: function (recordInfo) { + _.defaults(self.options, recordInfo); + }, + })); + }, + /** + * @override + */ + willStart: function () { + var self = this; + var def = this._super.apply(this, arguments); + if (this.imageData.isExternalImage) { + return def; + } + + var defs = [def]; + var params = {}; + var isDBImage = false; + var matchImageID = this.imageData.imageSrc.match(/^\/web\/image\/(\d+)/); + if (matchImageID) { + params.image_id = parseInt(matchImageID[1]); + isDBImage = true; + } else { + var matchXmlID = this.imageData.imageSrc.match(/^\/web\/image\/([^/?]+)/); + if (matchXmlID) { + params.xml_id = matchXmlID[1]; + isDBImage = true; + } + } + if (isDBImage) { + defs.push(this._rpc({ + route: '/web_editor/get_image_info', + params: params, + }).then(function (res) { + _.extend(self.imageData, res); + })); + } + return $.when.apply($, defs); + }, + /** + * @override + */ + start: function () { + this.$cropperImage = this.$('.o_cropper_image'); + if (this.$cropperImage.length) { + var data = this.$media.data(); + var ratio = 0; + for (var i = 0; i < this.aspectRatioList.length; i++) { + if (this.aspectRatioList[i][1] === data.aspectRatio) { + ratio = this.aspectRatioList[i][2]; + break; + } + } + this.$cropperImage.cropper({ + viewMode: 1, + autoCropArea: 1, + aspectRatio: ratio, + data: _.pick(data, 'x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY') + }); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + if (this.$cropperImage.length) { + this.$cropperImage.cropper('destroy'); + } + this._super.apply(this, arguments); + }, + /** + * Updates the DOM image with cropped data and associates required + * information for a potential future save (where required cropped data + * attachments will be created). + * + * @override + */ + save: function () { + var self = this; + var cropperData = this.$cropperImage.cropper('getData'); + + // Mark the media for later creation of required cropped attachments... + this.$media.addClass('o_cropped_img_to_save'); + + // ... and attach required data + this.$media.data('crop:resModel', this.options.res_model); + this.$media.data('crop:resID', this.options.res_id); + this.$media.data('crop:id', this.imageData.id); + this.$media.data('crop:mimetype', this.imageData.mimetype); + this.$media.data('crop:originalSrc', this.imageData.originalSrc); + + // Mark the media with the cropping information which is required for + // a future crop edition + this.$media + .attr('data-aspect-ratio', this.imageData.aspectRatio) + .data('aspectRatio', this.imageData.aspectRatio); + _.each(cropperData, function (value, key) { + key = _.str.dasherize(key); + self.$media.attr('data-' + key, value); + self.$media.data(key, value); + }); + + // Update the media with base64 source for preview before saving + var canvas = this.$cropperImage.cropper('getCroppedCanvas', { + width: cropperData.width, + height: cropperData.height, + }); + this.$media.attr('src', canvas.toDataURL(this.imageData.mimetype)); + + this.trigger('saved', { + media: this.$media[0], + }); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a crop option is clicked -> change the crop area accordingly. + * + * @private + * @param {MouseEvent} ev + */ + _onCropOptionClick: function (ev) { + var $option = $(ev.currentTarget); + var opt = $option.data('event'); + var value = $option.data('value'); + switch (opt) { + case 'ratio': + this.$cropperImage.cropper('reset'); + this.imageData.aspectRatio = $option.data('label'); + this.$cropperImage.cropper('setAspectRatio', value); + break; + case 'zoom': + case 'rotate': + case 'reset': + this.$cropperImage.cropper(opt, value); + break; + case 'flip': + var direction = value === 'horizontal' ? 'x' : 'y'; + var scaleAngle = -$option.data(direction); + $option.data(direction, scaleAngle); + this.$cropperImage.cropper('scale' + direction.toUpperCase(), scaleAngle); + break; + } + }, +}); + + +return CropImageDialog; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js new file mode 100644 index 00000000000..b46e43ab051 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js @@ -0,0 +1,58 @@ +odoo.define('wysiwyg.widgets.Dialog', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); + +var _t = core._t; + +/** + * Extend Dialog class to handle save/cancel of edition components. + */ +var SummernoteDialog = Dialog.extend({ + /** + * @constructor + */ + init: function (parent, options) { + this.options = options || {}; + this._super(parent, _.extend({}, { + buttons: [{ + text: this.options.save_text || _t("Save"), + classes: 'btn-primary', + click: this.save, + }, + { + text: _t("Discard"), + close: true, + } + ] + }, this.options)); + + this.destroyAction = 'cancel'; + + var self = this; + this.opened(function () { + self.$('input:first').focus(); + self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose); + }); + this.on('closed', this, function () { + this.trigger(this.destroyAction, this.final_data || null); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Called when the dialog is saved. Set the destroy action type to "save" + * and should set the final_data variable correctly before closing. + */ + save: function () { + this.destroyAction = "save"; + this.close(); + }, +}); + +return SummernoteDialog; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js new file mode 100644 index 00000000000..b0ef36bd584 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js @@ -0,0 +1,219 @@ +odoo.define('wysiwyg.widgets.LinkDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('wysiwyg.widgets.Dialog'); + +var _t = core._t; + +/** + * Allows to customize link content and style. + */ +var LinkDialog = Dialog.extend({ + template: 'wysiwyg.widgets.link', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web_editor/static/src/xml/editor.xml'] + ), + events: _.extend({}, Dialog.prototype.events || {}, { + 'input': '_onAnyChange', + 'change': '_onAnyChange', + 'input input[name="url"]': '_onURLInput', + }), + + /** + * @constructor + */ + init: function (parent, options, linkInfo) { + var self = this; + this.options = options || {}; + + this._super(parent, _.extend({ + title: _t("Link to"), + }, this.options)); + + this.trigger_up('getRecordInfo', { + recordInfo: this.options, + callback: function (recordInfo) { + _.defaults(self.options, recordInfo); + }, + }); + + this.data = linkInfo || {}; + this.needLabel = linkInfo.needLabel; + this.data.iniClassName = linkInfo.className; + var allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi; + var allBtnShapes = /\s*(rounded-circle|flat)\s*/gi; + this.data.className = linkInfo.className + .replace(allBtnClassSuffixes, ' ') + .replace(allBtnShapes, ' '); + }, + /** + * @override + */ + start: function () { + var self = this; + + this.$('input.link-style').prop('checked', false).first().prop('checked', true); + if (this.data.iniClassName) { + this.$('input[name="link_style_color"], select[name="link_style_size"] > option, select[name="link_style_shape"] > option').each(function () { + var $option = $(this); + if ($option.val() && self.data.iniClassName.match(new RegExp('(^|btn-| |btn-outline-)' + $option.val()))) { + if ($option.is("input")) { + $option.prop("checked", true); + } else { + $option.parent().find('option').removeAttr('selected').removeProp('selected'); + $option.parent().val($option.val()); + $option.attr('selected', 'selected').prop('selected', 'selected'); + } + } + }); + } + if (this.data.url) { + var match = /mailto:(.+)/.exec(this.data.url); + this.$('input[name="url"]').val(match ? match[1] : this.data.url); + } + + // Hide the duplicate color buttons (most of the times, primary = alpha + // and secondary = beta for example but this may depend on the theme) + this.opened().then(function () { + var colors = []; + _.each(self.$('.o_link_dialog_color .o_btn_preview'), function (btn) { + var $btn = $(btn); + var color = $btn.css('background-color'); + if (_.contains(colors, color)) { + $btn.hide(); // Not remove to be able to edit buttons with those styles + } else { + colors.push(color); + } + }); + }); + + this._adaptPreview(); + + this.$('input:visible:first').focus(); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var data = this._getData(); + if (data === null) { + var $url = this.$('input[name="url"]'); + $url.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); + $url.focus(); + return $.Deferred().reject(); + } + this.data.text = data.label; + this.data.url = data.url; + var allWhitespace = /\s+/gi; + var allStartAndEndSpace = /^\s+|\s+$/gi; + var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi; + this.data.className = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''); + if (data.classes.replace(allBtnTypes, ' ')) { + this.data.style = { + 'background-color': '', + 'color': '', + }; + } + this.data.isNewWindow = data.isNewWindow; + this.final_data = this.data; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adapt the link preview to changes. + * + * @private + */ + _adaptPreview: function () { + var $preview = this.$("#link-preview"); + var data = this._getData(); + if (data === null) { + return; + } + var floatClass = /float-\w+/; + $preview.attr({ + target: data.isNewWindow ? '_blank' : '', + href: data.url && data.url.length ? data.url : '#', + class: data.classes.replace(floatClass, '') + ' o_btn_preview', + }).html((data.label && data.label.length) ? data.label : data.url); + }, + /** + * Get the link's data (url, label and styles). + * + * @private + * @returns {Object} {label: String, url: String, classes: String, isNewWindow: Boolean} + */ + _getData: function () { + var $url = this.$('input[name="url"]'); + var url = $url.val(); + var label = this.$('input[name="label"]').val() || url; + + if (label && this.data.images) { + for (var i = 0; i < this.data.images.length; i++) { + label = label.replace('<', "<").replace('>', ">").replace(/\[IMG\]/, this.data.images[i].outerHTML); + } + } + + if ($url.prop('required') && (!url || !$url[0].checkValidity())) { + return null; + } + + var style = this.$('input[name="link_style_color"]:checked').val() || ''; + var shape = this.$('select[name="link_style_shape"] option:selected').val() || ''; + var size = this.$('select[name="link_style_size"] option:selected').val() || ''; + var shapes = shape.split(','); + var outline = shapes[0] === 'outline'; + shape = shapes.slice(outline ? 1 : 0).join(' '); + var classes = (this.data.className || '') + + (style ? (' btn btn-' + (outline ? 'outline-' : '') + style) : '') + + (shape ? (' ' + shape) : '') + + (size ? (' btn-' + size) : ''); + var isNewWindow = this.$('input[name="is_new_window"]').prop('checked'); + + if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) { + url = ('mailto:' + url); + } + var allWhitespace = /\s+/gi; + var allStartAndEndSpace = /^\s+|\s+$/gi; + return { + label: label, + url: url, + classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''), + isNewWindow: isNewWindow, + }; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAnyChange: function () { + this._adaptPreview(); + }, + /** + * @private + */ + _onURLInput: function (ev) { + $(ev.currentTarget).closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); + var isLink = $(ev.currentTarget).val().indexOf('@') < 0; + this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink); + }, +}); + +return LinkDialog; +}); diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index 9dad13a0b2b..0b113430352 100644 --- a/addons/web_editor/static/src/js/widgets/widgets.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -1,112 +1,16 @@ -odoo.define('web_editor.widget', function (require) { +odoo.define('wysiwyg.widgets.media', function (require) { 'use strict'; -var ajax = require('web.ajax'); -var base = require('web_editor.base'); var core = require('web.core'); -var DialogBase = require('web.Dialog'); +var Dialog = require('web.Dialog'); +var fonts = require('wysiwyg.fonts'); var Widget = require('web.Widget'); -var weContext = require("web_editor.context"); +var concurrency = require('web.concurrency'); var QWeb = core.qweb; -var range = $.summernote.core.range; -var dom = $.summernote.core.dom; var _t = core._t; -/** - * @todo we should either get rid of this or move it somewhere else - */ -function simulateMouseEvent(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); -} - -/** - * Extend Dialog class to handle save/cancel of edition components. - */ -var Dialog = DialogBase.extend({ - /** - * @constructor - */ - init: function (parent, options) { - options = options || {}; - this._super(parent, _.extend({}, { - buttons: [ - {text: options.save_text || _t("Save"), classes: 'btn-primary', click: this.save}, - {text: _t("Discard"), close: true} - ] - }, options)); - - this.destroyAction = 'cancel'; - - var self = this; - this.opened().then(function () { - self.$('input:first').focus(); - }); - this.on('closed', this, function () { - this.trigger(this.destroyAction, this.final_data || null); - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Called when the dialog is saved. Set the destroy action type to "save" - * and should set the final_data variable correctly before closing. - */ - save: function () { - this.destroyAction = "save"; - this.close(); - }, -}); - -/** - * Let users change the alt & title of a media. - */ -var AltDialog = Dialog.extend({ - template: 'web_editor.dialog.alt', - xmlDependencies: Dialog.prototype.xmlDependencies.concat( - ['/web_editor/static/src/xml/editor.xml'] - ), - - /** - * @constructor - */ - init: function (parent, options, $editable, media) { - this._super(parent, _.extend({}, { - title: _t("Change media description and tooltip") - }, options)); - this.$editable = $editable; - this.media = media; - this.alt = ($(this.media).attr('alt') || "").replace(/"/g, '"'); - this.tag_title = ($(this.media).attr('title') || "").replace(/"/g, '"'); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - save: function () { - var self = this; - range.createFromNode(this.media).select(); - this.$editable.data('NoteHistory').recordUndo(); - var alt = this.$('#alt').val(); - var title = this.$('#title').val(); - $(this.media).attr('alt', alt ? alt.replace(/"/g, """) : null).attr('title', title ? title.replace(/"/g, """) : null); - _.defer(function () { - simulateMouseEvent(self.media, 'mouseup'); - }); - return this._super.apply(this, arguments); - }, -}); - var MediaWidget = Widget.extend({ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], events: { @@ -156,14 +60,6 @@ var MediaWidget = Widget.extend({ * @abstract */ _clear: function () {}, - /** - * @private - */ - _replaceMedia: function ($media) { - this.$media.replaceWith($media); - this.$media = $media; - this.media = $media[0]; - }, //-------------------------------------------------------------------------- // Handlers @@ -182,13 +78,13 @@ var MediaWidget = Widget.extend({ * Let users choose an image, including uploading a new image in odoo. */ var ImageWidget = MediaWidget.extend({ - template: 'web_editor.dialog.image', + template: 'wysiwyg.widgets.image', events: _.extend({}, MediaWidget.prototype.events || {}, { 'click .o_upload_media_button': '_onUploadButtonClick', 'click .o_upload_media_button_no_optimization': '_onUploadButtonNoOptimizationClick', 'change input[type=file]': '_onImageSelection', 'click .o_upload_media_url_button': '_onUploadURLButtonClick', - 'input input.o_we_url_input': '_onURLInputChange', + 'input input[name="url"]': '_onURLInputChange', 'click .existing-attachments [data-src]': '_onImageClick', 'dblclick .existing-attachments [data-src]': '_onImageDblClick', 'click .o_existing_attachment_remove': '_onRemoveClick', @@ -203,21 +99,14 @@ var ImageWidget = MediaWidget.extend({ */ init: function (parent, media, options) { this._super.apply(this, arguments); + this._mutex = new concurrency.Mutex(); this.imagesRows = this.IMAGES_ROWS; this.IMAGES_DISPLAYED_TOTAL = this.IMAGES_PER_ROW * this.imagesRows; this.options = options; + this.context = options.context; this.accept = options.accept || (options.document ? '*/*' : 'image/*'); - if (options.domain) { - this.domain = typeof options.domain === 'function' ? options.domain() : options.domain; - } else if (options.res_id) { - this.domain = ['|', - '&', ['res_model', '=', options.res_model], ['res_id', '=', options.res_id], - ['res_model', '=', 'ir.ui.view']]; - } else { - this.domain = [['res_model', '=', 'ir.ui.view']]; - } this.multiImages = options.multiImages; @@ -269,75 +158,7 @@ var ImageWidget = MediaWidget.extend({ * @override */ save: function () { - var self = this; - if (this.multiImages) { - return this.images; - } - - var img = this.images[0]; - if (!img) { - return this.media; - } - - var def = $.when(); - if (!img.access_token) { - def = this._rpc({ - model: 'ir.attachment', - method: 'generate_access_token', - args: [[img.id]] - }).then(function (access_token) { - img.access_token = access_token[0]; - }); - } - - return def.then(function () { - if (!img.isDocument) { - if (img.access_token && self.options.res_model !== 'ir.ui.view') { - img.src += _.str.sprintf('?access_token=%s', img.access_token); - } - if (!self.$media.is('img')) { - // Note: by default the images receive the bootstrap opt-in - // img-fluid class. We cannot make them all responsive - // by design because of libraries and client databases img. - self._replaceMedia($('<img/>', {class: 'img-fluid o_we_custom_image'})); - } - self.$media.attr('src', img.src); - - } else { - if (!self.$media.is('a')) { - $('.note-control-selection').hide(); - self._replaceMedia($('<a/>')); - } - var href = '/web/content/' + img.id + '?'; - if (img.access_token && self.options.res_model !== 'ir.ui.view') { - href += _.str.sprintf('access_token=%s&', img.access_token); - } - href += 'unique=' + img.checksum + '&download=true'; - self.$media.attr('href', href); - self.$media.addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype); - } - - self.$media.attr('alt', img.alt); - var style = self.style; - if (style) { - self.$media.css(style); - } - - if (self.options.onUpload) { - // We consider that when selecting an image it is as if we upload it in the html content. - self.options.onUpload([img]); - } - - // Remove crop related attributes - if (self.$media.attr('data-aspect-ratio')) { - var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y']; - _.each(attrs, function (attr) { - self.$media.removeData(attr); - self.$media.removeAttr('data-' + attr); - }); - } - return self.media; - }); + return this._mutex.exec(this._save.bind(this)); }, /** * @override @@ -347,21 +168,15 @@ var ImageWidget = MediaWidget.extend({ if (!noRender) { this.$('input.o_we_url_input').val('').trigger('input').trigger('change'); } - // TODO: Expand this for adding SVG - var domain = this.domain.concat(['|', ['mimetype', '=', false], ['mimetype', this.options.document ? 'not in' : 'in', ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png']]]); - if (needle && needle.length) { - domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]); - } - domain.push('|', ['datas_fname', '=', false], '!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']); return this._rpc({ model: 'ir.attachment', method: 'search_read', args: [], kwargs: { - domain: domain, + domain: this._getAttachmentsDomain(needle), fields: ['name', 'datas_fname', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'access_token'], order: [{name: 'id', asc: false}], - context: weContext.get(), + context: this.context, }, }).then(function (records) { self.records = _.chain(records) @@ -415,7 +230,62 @@ var ImageWidget = MediaWidget.extend({ * @override */ _clear: function () { - this.media.className = this.media.className.replace(/(^|\s+)((img(\s|$)|img-(?!circle|rounded|thumbnail))[^\s]*)/g, ' '); + if (!this.$media.is('img')) { + return; + } + var allImgClasses = /(^|\s+)((img(\s|$)|img-(?!circle|rounded|thumbnail))[^\s]*)/g; + var allImgClassModifiers = /(^|\s+)(rounded-circle|shadow|rounded|img-thumbnail|mx-auto)([^\s]*)/g; + this.media.className = this.media.className && this.media.className + .replace('o_we_custom_image', '') + .replace(allImgClasses, ' ') + .replace(allImgClassModifiers, ' '); + }, + /** + * Returns the domain for attachments used in media dialog. + * We look for attachments related to the current document. If there is a value for the model + * field, it is used to search attachments, and the attachments from the current document are + * filtered to display only user-created documents. + * In the case of a wizard such as mail, we have the documents uploaded and those of the model + * + * @private + * @params {string} needle + * @returns {Array} "ir.attachment" odoo domain. + */ + _getAttachmentsDomain: function (needle) { + var domain = this.options.attachmentIDs && this.options.attachmentIDs.length ? ['|', ['id', 'in', this.options.attachmentIDs]] : []; + + var attachedDocumentDomain = [ + '&', + ['res_model', '=', this.options.res_model], + ['res_id', '=', this.options.res_id|0] + ]; + // if the document is not yet created, do not see the documents of other users + if (!this.options.res_id) { + attachedDocumentDomain.unshift('&'); + attachedDocumentDomain.push(['create_uid', '=', this.options.user_id]); + } + if (this.options.data_res_model) { + var relatedDomain = ['&', + ['res_model', '=', this.options.data_res_model], + ['res_id', '=', this.options.data_res_id|0]]; + if (!this.options.data_res_id) { + relatedDomain.unshift('&'); + relatedDomain.push(['create_uid', '=', session.uid]); + } + domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain); + } else { + domain = domain.concat(attachedDocumentDomain); + } + domain = ['|', ['public', '=', true]].concat(domain); + + domain.push('|', + ['mimetype', '=', false], + ['mimetype', this.options.document ? 'not in' : 'in', ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png']]); + if (needle && needle.length) { + domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]); + } + domain.push('|', ['datas_fname', '=', false], '!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']); + return domain; }, /** * @private @@ -458,9 +328,9 @@ var ImageWidget = MediaWidget.extend({ this.$('.form-text').empty(); - // Render menu & content + // Render menu & content this.$('.existing-attachments').replaceWith( - QWeb.render('web_editor.dialog.files.existing.content', { + QWeb.render('wysiwyg.widgets.files.existing.content', { rows: rows, isDocument: this.options.document, withEffect: withEffect, @@ -493,12 +363,88 @@ var ImageWidget = MediaWidget.extend({ /** * @private */ + _save: function () { + var self = this; + if (this.multiImages) { + return this.images; + } + + var img = this.images[0]; + if (!img) { + return this.media; + } + + var def = $.when(); + if (!img.access_token) { + def = this._rpc({ + model: 'ir.attachment', + method: 'generate_access_token', + args: [[img.id]] + }).then(function (access_token) { + img.access_token = access_token[0]; + }); + } + + return def.then(function () { + if (!img.isDocument) { + if (img.access_token && self.options.res_model !== 'ir.ui.view') { + img.src += _.str.sprintf('?access_token=%s', img.access_token); + } + if (!self.$media.is('img')) { + // Note: by default the images receive the bootstrap opt-in + // img-fluid class. We cannot make them all responsive + // by design because of libraries and client databases img. + self.$media = $('<img/>', {class: 'img-fluid o_we_custom_image'}); + self.media = self.$media[0]; + } + self.$media.attr('src', img.src); + + } else { + if (!self.$media.is('a')) { + $('.note-control-selection').hide(); + self.$media = $('<a/>'); + self.media = self.$media[0]; + } + var href = '/web/content/' + img.id + '?'; + if (img.access_token && self.options.res_model !== 'ir.ui.view') { + href += _.str.sprintf('access_token=%s&', img.access_token); + } + href += 'unique=' + img.checksum + '&download=true'; + self.$media.attr('href', href); + self.$media.addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype); + } + + self.$media.attr('alt', img.alt); + var style = self.style; + if (style) { + self.$media.css(style); + } + + if (self.options.onUpload) { + // We consider that when selecting an image it is as if we upload it in the html content. + self.options.onUpload([img]); + } + + // Remove crop related attributes + if (self.$media.attr('data-aspect-ratio')) { + var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y']; + _.each(attrs, function (attr) { + self.$media.removeData(attr); + self.$media.removeAttr('data-' + attr); + }); + } + return self.media; + }); + }, + /** + * @private + */ _toggleImage: function (attachment, clearSearch, forceSelect) { if (this.multiImages) { var img = _.select(this.images, function (v) { return v.id === attachment.id; }); if (img.length) { if (!forceSelect) { - this.images.splice(this.images.indexOf(img[0]), 1); + this.images.splice(this.images.indexOf(img[0]),1); } } else { this.images.push(attachment); @@ -516,16 +462,20 @@ var ImageWidget = MediaWidget.extend({ * @private */ _uploadFile: function () { + return this._mutex.exec(this._uploadImageIframe.bind(this)); + }, + _uploadImageIframe: function () { var self = this; - + var def = $.Deferred(); /** * @todo file upload cannot be handled with _rpc smoothly. This uses the * form posting in iframe trick to handle the upload. */ - var $iframe = this.$('.o_file_upload_iframe'); + var $iframe = this.$('iframe'); $iframe.on('load', function () { var iWindow = $iframe[0].contentWindow; - var attachments = iWindow.attachments; + + var attachments = iWindow.attachments || []; var error = iWindow.error; self.$('.well > span').remove(); @@ -538,7 +488,7 @@ var ImageWidget = MediaWidget.extend({ _processFile(null, error || !attachments.length); } self.images = attachments; - for (var i = 0; i < attachments.length; i++) { + for (var i = 0 ; i < attachments.length ; i++) { _processFile(attachments[i], error); } @@ -546,21 +496,32 @@ var ImageWidget = MediaWidget.extend({ self.options.onUpload(attachments); } + def.resolve(); + function _processFile(attachment, error) { + var $button = self.$('.o_upload_image_button'); if (!error) { + $button.addClass('btn-success'); self._toggleImage(attachment, true); } else { + $button.addClass('btn-danger'); self.$el.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); self.$el.find('.form-text').text(error); } + + if (!self.multiImages) { + self.trigger_up('save_request'); + } } }); this.$el.submit(); - // Empty file input this.$('.o_file_input').val(''); + + return def; }, + //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- @@ -568,12 +529,12 @@ var ImageWidget = MediaWidget.extend({ /** * @private */ - _onImageClick: function (ev, forceSelect) { + _onImageClick: function (ev, force_select) { var $img = $(ev.currentTarget); var attachment = _.find(this.records, function (record) { return record.id === $img.data('id'); }); - this._toggleImage(attachment, false, forceSelect); + this._toggleImage(attachment, false, force_select); }, /** * @private @@ -598,14 +559,13 @@ var ImageWidget = MediaWidget.extend({ */ _onRemoveClick: function (ev) { var self = this; - DialogBase.confirm(this, _t("Are you sure you want to delete this file ?"), { + Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), { confirm_callback: function () { var $helpBlock = this.$('.form-text').empty(); var $a = $(ev.currentTarget); var id = parseInt($a.data('id'), 10); var attachment = _.findWhere(this.records, {id: id}); - - return self._rpc({ + return self._rpc({ route: '/web_editor/attachment/remove', params: { ids: [id], @@ -613,10 +573,10 @@ var ImageWidget = MediaWidget.extend({ }).then(function (prevented) { if (_.isEmpty(prevented)) { self.records = _.without(self.records, attachment); - self.search(''); + self._renderImages(); return; } - $helpBlock.replaceWith(QWeb.render('web_editor.dialog.image.existing.error', { + $helpBlock.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', { views: prevented[id], })); }); @@ -690,7 +650,7 @@ var ImageWidget = MediaWidget.extend({ * css files. */ var IconWidget = MediaWidget.extend({ - template: 'web_editor.dialog.font-icons', + template: 'wysiwyg.widgets.font-icons', events: _.extend({}, MediaWidget.prototype.events || {}, { 'click .font-icons-icon': '_onIconClick', 'dblclick .font-icons-icon': '_onIconDblClick', @@ -702,8 +662,8 @@ var IconWidget = MediaWidget.extend({ init: function (parent, media) { this._super.apply(this, arguments); - base.computeFonts(); - this.iconsParser = base.fontIcons; + fonts.computeFonts(); + this.iconsParser = fonts.fontIcons; this.alias = _.flatten(_.map(this.iconsParser, function (data) { return data.alias; })); @@ -721,7 +681,7 @@ var IconWidget = MediaWidget.extend({ this._highlightSelectedIcon(); } } - this.nonIconClasses = _.without(classes, this.selectedIcon); + this.nonIconClasses = _.without(classes, 'media_iframe_video', this.selectedIcon); return this._super.apply(this, arguments); }, @@ -740,12 +700,13 @@ var IconWidget = MediaWidget.extend({ if (!this.$media.is('span')) { var $span = $('<span/>'); $span.data(this.$media.data()); - this._replaceMedia($span); + this.$media = $span; + this.media = this.$media[0]; style = style.replace(/\s*width:[^;]+/, ''); } this.$media.attr({ class: _.compact(finalClasses).join(' '), - style: style, + style: style || null, }); return this.media; }, @@ -771,7 +732,7 @@ var IconWidget = MediaWidget.extend({ }); } this.$('div.font-icons-icons').html( - QWeb.render('web_editor.dialog.font-icons.icons', {iconsParser: iconsParser}) + QWeb.render('wysiwyg.widgets.font-icons.icons', {iconsParser: iconsParser}) ); }, @@ -783,7 +744,8 @@ var IconWidget = MediaWidget.extend({ * @override */ _clear: function () { - this.media.className = this.media.className.replace(/(^|\s)(fa(\s|$)|fa-[^\s]*)/g, ' '); + var allFaClasses = /(^|\s)(fa(\s|$)|fa-[^\s]*)/g; + this.media.className = this.media.className && this.media.className.replace(allFaClasses, ' '); }, /** * @private @@ -845,7 +807,7 @@ var IconWidget = MediaWidget.extend({ * Let users choose a video, support all summernote video, and embed iframe. */ var VideoWidget = MediaWidget.extend({ - template: 'web_editor.dialog.video', + template: 'wysiwyg.widgets.video', events: _.extend({}, MediaWidget.prototype.events || {}, { 'change .o_video_dialog_options input': '_onUpdateVideoOption', 'input textarea#o_video_text': '_onVideoCodeInput', @@ -865,8 +827,8 @@ var VideoWidget = MediaWidget.extend({ start: function () { this.$content = this.$('.o_video_dialog_iframe'); - var $media = $(this.media); - if ($media.hasClass('media_iframe_video')) { + if (this.media) { + var $media = $(this.media); var src = $media.data('oe-expression') || $media.data('src') || ''; this.$('textarea#o_video_text').val(src); @@ -894,13 +856,14 @@ var VideoWidget = MediaWidget.extend({ save: function () { this._updateVideo(); if (this.$('.o_video_dialog_iframe').is('iframe')) { - this._replaceMedia($( + this.$media = $( '<div class="media_iframe_video" data-oe-expression="' + this.$content.attr('src') + '">' + '<div class="css_editable_mode_display"> </div>' + '<div class="media_iframe_video_size" contenteditable="false"> </div>' + '<iframe src="' + this.$content.attr('src') + '" frameborder="0" contenteditable="false"></iframe>' + '</div>' - )); + ); + this.media = this.$media[0]; } return this.media; }, @@ -920,7 +883,9 @@ var VideoWidget = MediaWidget.extend({ this.media.dataset.src = undefined; } } - this.media.className = this.media.className.replace(/(^|\s)media_iframe_video(\s|$)/g, ' '); + var allVideoClasses = /(^|\s)media_iframe_video(\s|$)/g; + this.media.className = this.media.className && this.media.className.replace(allVideoClasses, ' '); + this.media.innerHTML = ''; }, /** * Creates a video node according to the given URL and options. If not @@ -1123,673 +1088,10 @@ var VideoWidget = MediaWidget.extend({ }, }); -/** - * MediaDialog widget. Lets users change a media, including uploading a - * new image, font awsome or video and can change a media into an other - * media. - */ -var MediaDialog = Dialog.extend({ - template: 'web_editor.dialog.media', - xmlDependencies: Dialog.prototype.xmlDependencies.concat( - ['/web_editor/static/src/xml/editor.xml'] - ), - events: _.extend({}, Dialog.prototype.events, { - 'shown.bs.tab a[data-toggle="tab"]': '_onTabChange', - }), - custom_events: _.extend({}, Dialog.prototype.custom_events || {}, { - save_request: '_onSaveRequest', - }), - - /** - * @constructor - */ - init: function (parent, options, $editable, media) { - var self = this; - this._super(parent, _.extend({}, { - title: _t("Select a Media"), - save_text: _t("Add"), - }, options)); - - if ($editable) { - this.$editable = $editable; - this.rte = this.$editable.rte || this.$editable.data('rte'); - } - - this.media = media; - this.$media = $(media); - this.range = range.create(); - - this.multiImages = options.multiImages; - var onlyImages = options.onlyImages || this.multiImages || (this.media && (this.$media.parent().data('oeField') === 'image' || this.$media.parent().data('oeType') === 'image')); - this.noImages = options.noImages; - this.noDocuments = onlyImages || options.noDocuments; - this.noIcons = onlyImages || options.noIcons; - this.noVideos = onlyImages || options.noVideos; - - if (!this.noImages) { - this.imageDialog = new ImageWidget(this, this.media, options); - } - if (!this.noDocuments) { - this.documentDialog = new ImageWidget(this, this.media, _.extend({}, options, {document: true})); - } - if (!this.noIcons) { - this.iconDialog = new IconWidget(this, this.media, options); - } - if (!this.noVideos) { - this.videoDialog = new VideoWidget(this, this.media, options); - } - - this.opened(function () { - var tabToShow = 'icon'; - if (!self.media || self.$media.is('img')) { - tabToShow = 'image'; - } else if (self.$media.is('a.o_image')) { - tabToShow = 'document'; - } else if (self.$media.attr('class').match(/(^|\s)media_iframe_video($|\s)/)) { - tabToShow = 'video'; - } else if (self.$media.parent().attr('class').match(/(^|\s)media_iframe_video($|\s)/)) { - self.$media = self.$media.parent(); - self.media = self.$media[0]; - tabToShow = 'video'; - } - self.$('[href="#editor-media-' + tabToShow + '"]').tab('show'); - }); - }, - /** - * @override - */ - start: function () { - var self = this; - var defs = [this._super.apply(this, arguments)]; - this.$modal.addClass('note-image-dialog'); - this.$modal.find('.modal-dialog').addClass('o_select_media_dialog'); - - if (this.imageDialog) { - defs.push(this.imageDialog.appendTo(this.$("#editor-media-image"))); - } - if (this.documentDialog) { - defs.push(this.documentDialog.appendTo(this.$("#editor-media-document"))); - } - if (this.iconDialog) { - defs.push(this.iconDialog.appendTo(this.$("#editor-media-icon"))); - } - if (this.videoDialog) { - defs.push(this.videoDialog.appendTo(this.$("#editor-media-video"))); - } - - return $.when.apply($, defs).then(function () { - self._setActive(self.imageDialog); - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - save: function () { - var self = this; - var args = arguments; - var _super = this._super; - if (this.multiImages) { - // In the case of multi images selection we suppose this was not to - // replace an old media, so we only retrieve the images and save. - return $.when(this.active.save()).then(function (data) { - self.final_data = data; - return _super.apply(self, args); - }); - } - - if (this.rte) { - this.range.select(); - this.rte.historyRecordUndo(this.media); - } - - if (this.media) { - this.$media.html(''); - _.each(['imageDialog', 'documentDialog', 'iconDialog', 'videoDialog'], function (v) { - // Note: hack since imageDialog is the same type as the documentDialog - if (self[v] && self.active._clear.toString() !== self[v]._clear.toString()) { - self[v].clear(); - } - }); - } - - return $.when(this.active.save()).then(function (media) { - if (!self.media && media) { - self.range.insertNode(media, true); - } - self.media = media; - self.$media = $(media); - - self.final_data = self.media; - $(self.final_data).trigger('input').trigger('save'); - $(document.body).trigger("media-saved", self.final_data); // TODO get rid of this - - // Update editor bar after image edition (in case the image change to icon or other) - _.defer(function () { - if (!self.media || !self.media.parentNode) { - return; - } - range.createFromNode(self.media).select(); - simulateMouseEvent(self.media, 'mousedown'); - simulateMouseEvent(self.media, 'mouseup'); - }); - return _super.apply(self, args); - }); - }, - - //-------------------------------------------------------------------------- - // - //-------------------------------------------------------------------------- - - /** - * @private - */ - _setActive: function (widget) { - this.active = widget; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onSaveRequest: function (ev) { - ev.stopPropagation(); - this.save(); - }, - /** - * @private - */ - _onTabChange: function (ev) { - var $target = $(ev.target); - if ($target.is('[href="#editor-media-image"]')) { - this._setActive(this.imageDialog); - } else if ($target.is('[href="#editor-media-document"]')) { - this._setActive(this.documentDialog); - } else if ($target.is('[href="#editor-media-icon"]')) { - this._setActive(this.active = this.iconDialog); - } else if ($target.is('[href="#editor-media-video"]')) { - this._setActive(this.active = this.videoDialog); - } - }, -}); - -/** - * Allows to customize link content and style. - */ -var LinkDialog = Dialog.extend({ - template: 'web_editor.dialog.link', - xmlDependencies: Dialog.prototype.xmlDependencies.concat( - ['/web_editor/static/src/xml/editor.xml'] - ), - events: _.extend({}, Dialog.prototype.events || {}, { - 'input': '_onAnyChange', - 'change': '_onAnyChange', - 'input input[name="url"]': '_onURLInput', - }), - - /** - * @constructor - */ - init: function (parent, options, editable, linkInfo) { - this._super(parent, _.extend({ - title: _t("Link to"), - }, options || {})); - - this.editable = editable; - this.data = linkInfo || {}; - - this.data.className = ""; - - var r = this.data.range; - this.needLabel = !r || (r.sc === r.ec && r.so === r.eo); - - if (this.data.range) { - this.data.iniClassName = $(this.data.range.sc).filter("a").attr("class") || ""; - this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' '); - - var isLink = this.data.range.isOnAnchor(); - - var sc = r.sc; - var so = r.so; - var ec = r.ec; - var eo = r.eo; - - var nodes; - if (!isLink) { - if (sc.tagName) { - sc = dom.firstChild(so ? sc.childNodes[so] : sc); - so = 0; - } else if (so !== sc.textContent.length) { - if (sc === ec) { - ec = sc = sc.splitText(so); - eo -= so; - } else { - sc = sc.splitText(so); - } - so = 0; - } - if (ec.tagName) { - ec = dom.lastChild(eo ? ec.childNodes[eo - 1] : ec); - eo = ec.textContent.length; - } else if (eo !== ec.textContent.length) { - ec.splitText(eo); - } - - nodes = dom.listBetween(sc, ec); - - // browsers can't target a picture or void node - if (dom.isVoid(sc) || dom.isImg(sc)) { - so = dom.listPrev(sc).length - 1; - sc = sc.parentNode; - } - if (dom.isBR(ec)) { - eo = dom.listPrev(ec).length - 1; - ec = ec.parentNode; - } else if (dom.isVoid(ec) || dom.isImg(sc)) { - eo = dom.listPrev(ec).length; - ec = ec.parentNode; - } - - this.data.range = range.create(sc, so, ec, eo); - this.data.range.select(); - } else { - nodes = dom.ancestor(sc, dom.isAnchor).childNodes; - } - - if (dom.isImg(sc) && nodes.indexOf(sc) === -1) { - nodes.push(sc); - } - if (nodes.length > 1 || dom.ancestor(nodes[0], dom.isImg)) { - var text = ""; - this.data.images = []; - for (var i = 0; i < nodes.length; i++) { - if (dom.ancestor(nodes[i], dom.isImg)) { - this.data.images.push(dom.ancestor(nodes[i], dom.isImg)); - text += '[IMG]'; - } else if (!isLink && nodes[i].nodeType === 1) { - // just use text nodes from listBetween - } else if (!isLink && i === 0) { - text += nodes[i].textContent.slice(so, Infinity); - } else if (!isLink && i === nodes.length - 1) { - text += nodes[i].textContent.slice(0, eo); - } else { - text += nodes[i].textContent; - } - } - this.data.text = text; - } - } - - this.data.text = this.data.text.replace(/[ \t\r\n]+/g, ' '); - - this._onURLInput = _.debounce(this._onURLInput, 400); - }, - /** - * @override - */ - start: function () { - var self = this; - - this.$('input.link-style').prop('checked', false).first().prop('checked', true); - if (this.data.iniClassName) { - _.each(this.$('input.link-style, select.link-style > option'), function (el) { - var $option = $(el); - if ($option.val() && self.data.iniClassName.indexOf($option.val()) >= 0) { - if ($option.is("input")) { - $option.prop("checked", true); - } else { - $option.parent().val($option.val()); - } - } - }); - } - if (this.data.url) { - var match = /mailto:(.+)/.exec(this.data.url); - this.$('input[name="url"]').val(match ? match[1] : this.data.url); - } - - // Hide the duplicate color buttons (most of the times, primary = alpha - // and secondary = beta for example but this may depend on the theme) - this.opened().then(function () { - var colors = []; - _.each(self.$('.o_btn_preview.o_link_dialog_color_item'), function (btn) { - var $btn = $(btn); - var color = $btn.css('background-color'); - if (_.contains(colors, color)) { - $btn.hide(); // Not remove to be able to edit buttons with those styles - } else { - colors.push(color); - } - }); - }); - - this._adaptPreview(); - - this.$('input:visible:first').focus(); - - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - save: function () { - var data = this._getData(); - if (data === null) { - var $url = this.$('input[name="url"]'); - $url.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); - $url.focus(); - return $.Deferred().reject(); - } - this.data.text = data.label; - this.data.url = data.url; - this.data.className = data.classes.replace(/\s+/gi, ' ').replace(/^\s+|\s+$/gi, ''); - if (data.classes.replace(/(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi, ' ')) { - this.data.style = {'background-color': '', 'color': ''}; - } - this.data.isNewWindow = data.isNewWindow; - this.final_data = this.data; - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _adaptPreview: function () { - var $preview = this.$("#link-preview"); - var data = this._getData(); - if (data === null) { - return; - } - $preview.attr({ - target: data.isNewWindow ? '_blank' : '', - href: data.url && data.url.length ? data.url : '#', - class: data.classes.replace(/float-\w+/, '') + ' o_btn_preview', - }).html((data.label && data.label.length) ? data.label : data.url); - }, - /** - * @private - */ - _getData: function () { - var $url = this.$('input[name="url"]'); - var url = $url.val(); - var label = this.$('input[name="label"]').val() || url; - - if (label && this.data.images) { - for (var i = 0; i < this.data.images.length; i++) { - label = label.replace(/</, "<").replace(/>/, ">").replace(/\[IMG\]/, this.data.images[i].outerHTML); - } - } - - if ($url.prop('required') && (!url || !$url[0].checkValidity())) { - return null; - } - - var style = this.$('input[name="link_style_color"]:checked').val() || ''; - var shape = this.$('select[name="link_style_shape"]').val() || ''; - var size = this.$('select[name="link_style_size"]').val() || ''; - var shapes = shape.split(','); - var outline = shapes[0] === 'outline'; - shape = shapes.slice(outline ? 1 : 0).join(' '); - var classes = (this.data.className || '') - + (style ? (' btn btn-' + (outline ? 'outline-' : '') + style) : '') - + (shape ? (' ' + shape) : '') - + (size ? (' btn-' + size) : ''); - var isNewWindow = this.$('input[name="is_new_window"]').prop('checked'); - - if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) { - url = ('mailto:' + url); - } - return { - label: label, - url: url, - classes: classes, - isNewWindow: isNewWindow, - }; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onAnyChange: function () { - this._adaptPreview(); - }, - /** - * @private - */ - _onURLInput: function () { - var $linkUrlInput = this.$('#o_link_dialog_url_input'); - $linkUrlInput.closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); - var isLink = $linkUrlInput.val().indexOf('@') < 0; - this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink); - }, -}); - -/** - * CropImageDialog widget. Let users crop an image. - */ -var CropImageDialog = Dialog.extend({ - template: 'web_editor.dialog.crop_image', - xmlDependencies: Dialog.prototype.xmlDependencies.concat( - ['/web_editor/static/src/xml/editor.xml'] - ), - jsLibs: [ - '/web_editor/static/lib/cropper/js/cropper.js', - ], - cssLibs: [ - '/web_editor/static/lib/cropper/css/cropper.css', - ], - events: _.extend({}, Dialog.prototype.events, { - 'click .o_crop_options [data-event]': '_onCropOptionClick', - }), - - /** - * @constructor - */ - init: function (parent, options, $editable, media) { - this.media = media; - this.$media = $(this.media); - var src = this.$media.attr('src').split('?')[0]; - this.aspectRatioList = [ - [_t("Free"), '0/0', 0], - ["16:9", '16/9', 16 / 9], - ["4:3", '4/3', 4 / 3], - ["1:1", '1/1', 1], - ["2:3", '2/3', 2 / 3], - ]; - this.imageData = { - imageSrc: src, - originalSrc: this.$media.data('crop:originalSrc') || src, // the original src for cropped DB images will be fetched later - mimetype: this.$media.data('crop:mimetype') || (_.str.endsWith(src, '.png') ? 'image/png' : 'image/jpeg'), // the mimetype for DB images will be fetched later - aspectRatio: this.$media.data('aspectRatio') || this.aspectRatioList[0][1], - isExternalImage: src.substr(0, 5) !== 'data:' && src[0] !== '/' && src.indexOf(window.location.host) < 0, - }; - this.options = _.extend({ - title: _t("Crop Image"), - buttons: this.imageData.isExternalImage ? [{ - text: _t("Close"), - close: true, - }] : [{ - text: _t("Save"), - classes: 'btn-primary', - click: this.save, - }, { - text: _t("Discard"), - close: true, - }], - }, options || {}); - this._super(parent, this.options); - }, - /** - * @override - */ - willStart: function () { - var self = this; - var def = this._super.apply(this, arguments); - if (this.imageData.isExternalImage) { - return def; - } - - var defs = [def, ajax.loadLibs(this)]; - var params = {}; - var isDBImage = false; - var matchImageID = this.imageData.imageSrc.match(/^\/web\/image\/(\d+)/); - if (matchImageID) { - params['image_id'] = parseInt(matchImageID[1]); - isDBImage = true; - } else { - var matchXmlID = this.imageData.imageSrc.match(/^\/web\/image\/([^/?]+)/); - if (matchXmlID) { - params['xml_id'] = matchXmlID[1]; - isDBImage = true; - } - } - if (isDBImage) { - defs.push(this._rpc({ - route: '/web_editor/get_image_info', - params: params, - }).then(function (res) { - _.extend(self.imageData, res); - })); - } - return $.when.apply($, defs); - }, - /** - * @override - */ - start: function () { - this.$cropperImage = this.$('.o_cropper_image'); - if (this.$cropperImage.length) { - var data = this.$media.data(); - var ratio = 0; - for (var i = 0; i < this.aspectRatioList.length; i++) { - if (this.aspectRatioList[i][1] === data.aspectRatio) { - ratio = this.aspectRatioList[i][2]; - break; - } - } - this.$cropperImage.cropper({ - viewMode: 1, - autoCropArea: 1, - aspectRatio: ratio, - data: _.pick(data, 'x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY') - }); - } - return this._super.apply(this, arguments); - }, - /** - * @override - */ - destroy: function () { - if (this.$cropperImage.length) { - this.$cropperImage.cropper('destroy'); - } - this._super.apply(this, arguments); - }, - /** - * Updates the DOM image with cropped data and associates required - * information for a potential future save (where required cropped data - * attachments will be created). - * - * @override - */ - save: function () { - var self = this; - var cropperData = this.$cropperImage.cropper('getData'); - - // Mark the media for later creation of required cropped attachments... - this.$media.addClass('o_cropped_img_to_save'); - - // ... and attach required data - this.$media.data('crop:resModel', this.options.res_model); - this.$media.data('crop:resID', this.options.res_id); - this.$media.data('crop:id', this.imageData.id); - this.$media.data('crop:mimetype', this.imageData.mimetype); - this.$media.data('crop:originalSrc', this.imageData.originalSrc); - - // Mark the media with the cropping information which is required for - // a future crop edition - this.$media - .attr('data-aspect-ratio', this.imageData.aspectRatio) - .data('aspectRatio', this.imageData.aspectRatio); - _.each(cropperData, function (value, key) { - key = _.str.dasherize(key); - self.$media.attr('data-' + key, value); - self.$media.data(key, value); - }); - - // Update the media with base64 source for preview before saving - var canvas = this.$cropperImage.cropper('getCroppedCanvas', { - width: cropperData.width, - height: cropperData.height, - }); - this.$media.attr('src', canvas.toDataURL(this.imageData.mimetype)); - - this.$media.trigger('content_changed'); - - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called when a crop option is clicked -> change the crop area accordingly. - * - * @private - * @param {MouseEvent} ev - */ - _onCropOptionClick: function (ev) { - var $option = $(ev.currentTarget); - var opt = $option.data('event'); - var value = $option.data('value'); - switch (opt) { - case 'ratio': - this.$cropperImage.cropper('reset'); - this.imageData.aspectRatio = $option.data('label'); - this.$cropperImage.cropper('setAspectRatio', value); - break; - case 'zoom': - case 'rotate': - case 'reset': - this.$cropperImage.cropper(opt, value); - break; - case 'flip': - var direction = value === 'horizontal' ? 'x' : 'y'; - var scaleAngle = -$option.data(direction); - $option.data(direction, scaleAngle); - this.$cropperImage.cropper('scale' + direction.toUpperCase(), scaleAngle); - break; - } - }, -}); - return { - Dialog: Dialog, - AltDialog: AltDialog, - MediaDialog: MediaDialog, - LinkDialog: LinkDialog, - CropImageDialog: CropImageDialog, + MediaWidget: MediaWidget, ImageWidget: ImageWidget, + IconWidget: IconWidget, + VideoWidget: VideoWidget, }; }); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js new file mode 100644 index 00000000000..e6cb1277a7e --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js @@ -0,0 +1,207 @@ +odoo.define('wysiwyg.widgets.MediaDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var MediaModules = require('wysiwyg.widgets.media'); +var Dialog = require('wysiwyg.widgets.Dialog'); + +var _t = core._t; + +/** + * MediaDialog widget. Lets users change a media, including uploading a + * new image, font awsome or video and can change a media into an other + * media. + */ +var MediaDialog = Dialog.extend({ + template: 'wysiwyg.widgets.media', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web_editor/static/src/xml/editor.xml'] + ), + events: _.extend({}, Dialog.prototype.events, { + 'click a[data-toggle="tab"]': '_onTabChange', + }), + custom_events: _.extend({}, Dialog.prototype.custom_events || {}, { + save_request: '_onSaveRequest', + }), + + /** + * @constructor + */ + init: function (parent, options, media) { + var self = this; + options = options || {}; + + this._super(parent, _.extend({}, { + title: _t("Select a Media"), + save_text: _t("Add"), + }, options)); + + this.trigger_up('getRecordInfo', { + recordInfo: options, + type: 'media', + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + + this.media = media; + this.$media = $(media); + + this.multiImages = options.multiImages; + var onlyImages = options.onlyImages || this.multiImages || (this.media && (this.$media.parent().data('oeField') === 'image' || this.$media.parent().data('oeType') === 'image')); + this.noImages = options.noImages; + this.noDocuments = onlyImages || options.noDocuments; + this.noIcons = onlyImages || options.noIcons; + this.noVideos = onlyImages || options.noVideos; + + if (!this.noDocuments) { + this.documentDialog = new MediaModules.ImageWidget(this, this.media, _.extend({}, options, { + document: true, + })); + this.documentDialog.tabToShow = 'document'; + } + if (!this.noIcons) { + this.iconDialog = new MediaModules.IconWidget(this, this.media, options); + this.iconDialog.tabToShow = 'icon'; + } + if (!this.noVideos) { + this.videoDialog = new MediaModules.VideoWidget(this, this.media, options); + this.videoDialog.tabToShow = 'video'; + } + if (!this.noImages) { + this.imageDialog = new MediaModules.ImageWidget(this, this.media, options); + this.imageDialog.tabToShow = 'image'; + } + + this.active = this.imageDialog || this.documentDialog || this.iconDialog || this.videoDialog; + if (this.imageDialog && this.$media.is('img')) { + this.active = this.imageDialog; + } else if (this.documentDialog && this.$media.is('a.o_image')) { + this.active = this.documentDialog; + } else if (this.videoDialog && this.$media.hasClass('media_iframe_video')) { + this.active = this.videoDialog; + } else if (this.videoDialog && this.$media.parent().hasClass('media_iframe_video')) { + this.$media = this.$media.parent(); + this.media = this.$media[0]; + this.active = this.videoDialog; + } else if (this.iconDialog && this.$media.is('span, i')) { + this.active = this.iconDialog; + } + + this.opened(function () { + self.$('[href="#editor-media-' + self.active.tabToShow + '"]').tab('show'); + }); + }, + /** + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + this.$modal.addClass('note-image-dialog'); + this.$modal.find('.modal-dialog').addClass('o_select_media_dialog'); + + if (this.imageDialog) { + this.imageDialog.clear(); + } + if (this.documentDialog) { + this.documentDialog.clear(); + } + if (this.iconDialog) { + this.iconDialog.clear(); + } + if (this.videoDialog) { + this.videoDialog.clear(); + } + + if (this.imageDialog) { + defs.push(this.imageDialog.appendTo(this.$("#editor-media-image"))); + } + if (this.documentDialog) { + defs.push(this.documentDialog.appendTo(this.$("#editor-media-document"))); + } + if (this.iconDialog) { + defs.push(this.iconDialog.appendTo(this.$("#editor-media-icon"))); + } + if (this.videoDialog) { + defs.push(this.videoDialog.appendTo(this.$("#editor-media-video"))); + } + + return $.when.apply($, defs).then(function () { + self._setActive(self.active); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var self = this; + var args = arguments; + var _super = this._super; + if (this.multiImages) { + // In the case of multi images selection we suppose this was not to + // replace an old media, so we only retrieve the images and save. + return $.when(this.active.save()).then(function (data) { + self.final_data = data; + return _super.apply(self, args); + }); + } + + return $.when(this.active.save()).then(function (media) { + self.trigger('saved', { + attachments: self.active.images, + media: media, + }); + return _super.apply(self, args); + }); + }, + + //-------------------------------------------------------------------------- + // + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} widget + */ + _setActive: function (widget) { + this.active = widget; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onSaveRequest: function (ev) { + ev.stopPropagation(); + this.save(); + }, + /** + * @private + * @param {JQueryEvent} ev + */ + _onTabChange: function (ev) { + var $target = $(ev.target); + if ($target.is('[href="#editor-media-image"]')) { + this._setActive(this.imageDialog); + } else if ($target.is('[href="#editor-media-document"]')) { + this._setActive(this.documentDialog); + } else if ($target.is('[href="#editor-media-icon"]')) { + this._setActive(this.active = this.iconDialog); + } else if ($target.is('[href="#editor-media-video"]')) { + this._setActive(this.active = this.videoDialog); + } + }, +}); + +return MediaDialog; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js new file mode 100644 index 00000000000..18b88951c70 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js @@ -0,0 +1,26 @@ +odoo.define('wysiwyg.widgets', function (require) { +'use strict'; + +var Dialog = require('wysiwyg.widgets.Dialog'); +var AltDialog = require('wysiwyg.widgets.AltDialog'); +var MediaDialog = require('wysiwyg.widgets.MediaDialog'); +var LinkDialog = require('wysiwyg.widgets.LinkDialog'); +var CropImageDialog = require('wysiwyg.widgets.CropImageDialog'); +var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog'); + +var media = require('wysiwyg.widgets.media'); + +return { + Dialog: Dialog, + AltDialog: AltDialog, + MediaDialog: MediaDialog, + LinkDialog: LinkDialog, + CropImageDialog: CropImageDialog, + ColorpickerDialog: ColorpickerDialog, + + MediaWidget: media.MediaWidget, + ImageWidget: media.ImageWidget, + IconWidget: media.IconWidget, + VideoWidget: media.VideoWidget, +}; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js new file mode 100644 index 00000000000..b8ff583c6fa --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -0,0 +1,821 @@ +odoo.define('web_editor.wysiwyg', function (require) { +'use strict'; + +var Widget = require('web.Widget'); +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); +var modulesRegistry = require('web_editor.wysiwyg.plugin.registry'); +var wysiwygOptions = require('web_editor.wysiwyg.options'); + +var _t = core._t; + +var Wysiwyg = Widget.extend({ + xmlDependencies: [ + '/web_editor/static/src/xml/wysiwyg.xml', + ], + custom_events: { + getRecordInfo: '_onGetRecordInfo', + wysiwyg_blur: '_onWysiwygBlur', + }, + defaultOptions: { + codeview: config.debug + }, + + /** + * @params {Object} params + * @params {Object} params.recordInfo + * @params {Object} params.recordInfo.context + * @params {String} [params.recordInfo.context] + * @params {integer} [params.recordInfo.res_id] + * @params {String} [params.recordInfo.data_res_model] + * @params {integer} [params.recordInfo.data_res_id] + * @see _onGetRecordInfo + * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js + * @params {Object} params.attachments + * @see _onGetRecordInfo + * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js (for attachmentIDs) + * @params {function} params.generateOptions + * called with the summernote configuration object used before sending to summernote + * @see _editorOptions + **/ + init: function (parent, params) { + this._super.apply(this, arguments); + this.options = _.extend({}, this.defaultOptions, params); + this.attachments = this.options.attachments || []; + this.hints = []; + this.$el = null; + this._dirty = false; + this.id = _.uniqueId('wysiwyg_'); + }, + /** + * Load assets and color picker template then call summernote API + * and replace $el by the summernote editable node. + * + * @override + **/ + willStart: function () { + var self = this; + this.$target = this.$el; + this.$el = null; // temporary null to avoid hidden error, setElement when start + return this._super() + .then(function () { + return modulesRegistry.start(self).then(function () { + return self._loadInstance(); + }); + }); + }, + /** + * + * @override + */ + start: function () { + var value = this._summernote.code(); + this._value = value; + if (this._summernote.invoke('HelperPlugin.hasJinja', value)) { + this._summernote.invoke('codeview.forceActivate'); + } + return $.when(); + }, + /** + * @override + */ + destroy: function () { + if (this._summernote) { + // prevents the replacement of the target by the content of summernote + // (in order to be able to cancel) + var removeLayout = $.summernote.ui.removeLayout; + $.summernote.ui.removeLayout = function ($note, layoutInfo) { + layoutInfo.editor.remove(); + $note.show(); + }; + this._summernote.destroy(); + $.summernote.ui.removeLayout = removeLayout; + } + this.$target.removeAttr('data-wysiwyg-id'); + this.$target.removeData('wysiwyg'); + $(document).off('.' + this.id); + this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a step (undo) in editor. + */ + addHistoryStep: function () { + var editor = this._summernote.modules.editor; + editor.createRange(); + editor.history.recordUndo(); + }, + /** + * Return the editable area. + * + * @returns {jQuery} + */ + getEditable: function () { + if (this._summernote.invoke('HelperPlugin.hasJinja', this._summernote.code())) { + return this._summernote.layoutInfo.codable; + } else if (this._summernote.invoke('codeview.isActivated')) { + this._summernote.invoke('codeview.deactivate'); + } + return this._summernote.layoutInfo.editable; + }, + /** + * Perform undo or redo in the editor. + * + * @param {integer} step + */ + history: function (step) { + if (step < 0) { + while (step) { + this._summernote.modules.editor.history.rewind(); + step++; + } + } else if (step > 0) { + while (step) { + this._summernote.modules.editor.history.redo(); + step--; + } + } + }, + /** + * Return true if the content has changed. + * + * @returns {Boolean} + */ + isDirty: function () { + if (!this._dirty && this._value !== this._summernote.code()) { + console.warn("not dirty flag ? Please fix it."); + } + return this._value !== this._summernote.code(); + }, + /** + * Return true if the current node is unbreakable. + * An unbreakable node can be removed or added but can't by split into + * different nodes (for keypress and selection). + * An unbreakable node can contain nodes that can be edited. + * + * @param {Node} node + * @returns {Boolean} + */ + isUnbreakableNode: function (node) { + return ["TD", "TR", "TBODY", "TFOOT", "THEAD", "TABLE"].indexOf(node.tagName) !== -1 || $(node).is(this.getEditable()) || + !this.isEditableNode(node.parentNode) || !this.isEditableNode(node) || $.summernote.dom.isMedia(node); + }, + /** + * Return true if the current node is editable (for keypress and selection). + * + * @param {Node} node + * @returns {Boolean} + */ + isEditableNode: function (node) { + return $(node).is(':o_editable') && !$(node).is('table, thead, tbody, tfoot, tr'); + }, + /** + * Set the focus on the element. + */ + focus: function () { + this.$el.mousedown(); + }, + /** + * Get the value of the editable element. + * + * @param {object} [options] + * @param {Boolean} [options.keepPopover] + * @returns {String} + */ + getValue: function (options) { + if (!options || !options.keepPopover) { + this._summernote.invoke('editor.hidePopover'); + } + var $editable = this.getEditable().clone(); + $editable.find('.o_wysiwyg_to_remove').remove(); + $editable.find('[contenteditable]').removeAttr('contenteditable'); + $editable.find('.o_fake_not_editable').removeClass('o_fake_not_editable'); + $editable.find('.o_fake_editable').removeClass('o_fake_editable'); + $editable.find('[class=""]').removeAttr('class'); + $editable.find('[style=""]').removeAttr('style'); + $editable.find('[title=""]').removeAttr('title'); + $editable.find('[alt=""]').removeAttr('alt'); + $editable.find('a.o_image, span.fa, i.fa').html(''); + $editable.find('[aria-describedby]').removeAttr('aria-describedby').removeAttr('data-original-title'); + + return $editable.html() || $editable.val(); + }, + /** + * Save the content in the target + * - in init option beforeSave + * - receive editable jQuery DOM as attribute + * - called after deactivate codeview if needed + * @returns {$.Promise} + * - resolve with true if the content was dirty + */ + save: function () { + var isDirty = this.isDirty(); + var html = this.getValue(); + if (this.$target.is('textarea')) { + this.$target.val(html); + } else { + this.$target.html(html); + } + return $.when(isDirty, html); + }, + /** + * @param {String} value + * @param {Object} options + * @param {Boolean} [options.notifyChange] + * @returns {String} + */ + setValue: function (value, options) { + if (this._summernote.invoke('HelperPlugin.hasJinja', value)) { + this._summernote.invoke('codeview.forceActivate'); + } + this._dirty = true; + this._summernote.invoke('HistoryPlugin.clear'); + this._summernote.invoke('editor.hidePopover'); + this._summernote.invoke('editor.clearTarget'); + var $editable = this.getEditable().html(value + ''); + this._summernote.invoke('UnbreakablePlugin.secureArea'); + if (!options || options.notifyChange !== false) { + $editable.change(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Object} the summernote configuration + */ + _editorOptions: function () { + var self = this; + var allowAttachment = !this.options.noAttachment; + + var options = JSON.parse(JSON.stringify(wysiwygOptions)); + + options.parent = this; + options.lang = "odoo"; + + options.focus = false; + options.disableDragAndDrop = !allowAttachment; + options.styleTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre']; + options.fontSizes = [_t('Default'), '8', '9', '10', '11', '12', '13', '14', '18', '24', '36', '48', '62']; + options.minHeight = 180; + + options.keyMap.pc['CTRL+K'] = 'LinkPlugin.show'; + options.keyMap.mac['CMD+K'] = 'LinkPlugin.show'; + delete options.keyMap.pc['CTRL+LEFTBRACKET']; + delete options.keyMap.mac['CMD+LEFTBRACKET']; + delete options.keyMap.pc['CTRL+RIGHTBRACKET']; + delete options.keyMap.mac['CMD+RIGHTBRACKET']; + + options.toolbar = [ + ['style', ['style']], + ['font', ['bold', 'italic', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['color', ['colorpicker']], + ['para', ['ul', 'ol', 'paragraph']], + ['table', ['table']], + ['insert', allowAttachment ? ['linkPlugin', 'mediaPlugin'] : ['linkPlugin']], + ['history', ['undo', 'redo']], + ['view', this.options.codeview ? ['fullscreen', 'codeview', 'help'] : ['fullscreen', 'help']] + ]; + options.popover = { + image: [ + ['padding'], + ['imagesize', ['imageSizeAuto', 'imageSize100', 'imageSize50', 'imageSize25']], + ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']], + ['imageShape'], + ['cropImage'], + ['media', ['mediaPlugin', 'removePluginMedia']], + ['alt'] + ], + video: [ + ['padding'], + ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']], + ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']], + ['media', ['mediaPlugin', 'removePluginMedia']] + ], + icon: [ + ['padding'], + ['faSize'], + ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']], + ['faSpin'], + ['media', ['mediaPlugin', 'removePluginMedia']] + ], + document: [ + ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']], + ['media', ['mediaPlugin', 'removePluginMedia']] + ], + link: [ + ['link', ['linkPlugin', 'unlink']] + ], + table: [ + ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']], + ['delete', ['deleteRow', 'deleteCol', 'deleteTable']] + ], + }; + + options.callbacks = { + onBlur: this._onBlurEditable.bind(this), + onFocus: this._onFocusEditable.bind(this), + onChange: this._onChange.bind(this), + onImageUpload: this._onImageUpload.bind(this), + onFocusnode: this._onFocusnode.bind(this), + }; + + options.isUnbreakableNode = function (node) { + node = node && (node.tagName ? node : node.parentNode); + if (!node) { + return true; + } + return self.isUnbreakableNode(node); + }; + options.isEditableNode = function (node) { + node = node && (node.tagName ? node : node.parentNode); + if (!node) { + return false; + } + return self.isEditableNode(node); + }; + options.displayPopover = this._isDisplayingPopover.bind(this); + options.hasFocus = function () { + return self._isFocused; + }; + + if (this.options.generateOptions) { + this.options.generateOptions(options); + } + + return options; + }, + /** + * @private + * @returns {Object} modules list to load + */ + _getPlugins: function () { + return _.extend({}, wysiwygOptions.modules, modulesRegistry.plugins()); + }, + /** + * Return an object describing the linked record. + * + * @private + * @param {Object} options + * @returns {Object} {res_id, res_model, xpath} + */ + _getRecordInfo: function (options) { + var data = this.options.recordInfo || {}; + if (typeof data === 'function') { + data = data(options); + } + if (!data.context) { + throw new Error("Context is missing"); + } + return data; + }, + /** + * Return true if the editor is displaying the popover. + * + * @private + * @param {Node} node + * @returns {Boolean} + */ + _isDisplayingPopover: function (node) { + return true; + }, + /** + * Return true if the given node is in the editor. + * Note: a button in the MediaDialog returns true. + * + * @private + * @param {Node} node + * @returns {Boolean} + */ + _isEditorContent: function (node) { + if (this.el === node) { + return true; + } + if ($.contains(this.el, node)) { + return true; + } + + var children = this.getChildren(); + var allChildren = []; + var child; + while ((child = children.pop())) { + allChildren.push(child); + children = children.concat(child.getChildren()); + } + + var childrenDom = _.filter(_.unique(_.flatten(_.map(allChildren, function (obj) { + return _.map(obj, function (value) { + return value instanceof $ ? value.get() : value; + }); + }))), function (node) { + return node && node.DOCUMENT_NODE && node.tagName && node.tagName !== 'BODY' && node.tagName !== 'HTML'; + }); + return !!$(node).closest(childrenDom).length; + }, + /** + * Create an instance with the API lib. + * + * @private + * @returns {$.Promise} + */ + _loadInstance: function () { + var defaultOptions = this._editorOptions(); + var summernoteOptions = _.extend({ + id: this.id, + }, defaultOptions, _.omit(this.options, 'isEditableNode', 'isUnbreakableNode')); + + _.extend(summernoteOptions.callbacks, defaultOptions.callbacks, this.options.callbacks); + if (this.options.keyMap) { + _.defaults(summernoteOptions.keyMap.pc, defaultOptions.keyMap.pc); + _.each(summernoteOptions.keyMap.pc, function (v, k, o) { + if (!v) { + delete o[k]; + } + }); + _.defaults(summernoteOptions.keyMap.mac, defaultOptions.keyMap.mac); + _.each(summernoteOptions.keyMap.mac, function (v, k, o) { + if (!v) { + delete o[k]; + } + }); + } + + var plugins = _.extend(this._getPlugins(), this.options.plugins); + summernoteOptions.modules = _.omit(plugins, function (v) { + return !v; + }); + + if (this.$target.parent().length) { + summernoteOptions.container = this.$target.parent().css('position', 'relative')[0]; + } else { + summernoteOptions.container = this.$target[0].ownerDocument.body; + } + + this.$target.summernote(summernoteOptions); + + this._summernote = this.$target.data('summernote'); + this._summernote.layoutInfo.editable.data('wysiwyg', this); + this.$target.attr('data-wysiwyg-id', this.id).data('wysiwyg', this); + $('.note-editor, .note-popover').not('[data-wysiwyg-id]').attr('data-wysiwyg-id', this.id); + + this.setElement(this._summernote.layoutInfo.editor); + $(document).on('mousedown.' + this.id, this._onMouseDown.bind(this)); + $(document).on('mouseenter.' + this.id, '*', this._onMouseEnter.bind(this)); + $(document).on('mouseleave.' + this.id, '*', this._onMouseLeave.bind(this)); + $(document).on('mousemove.' + this.id, this._onMouseMove.bind(this)); + + this.$el.removeClass('card'); + + return $.when(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * trigger_up 'wysiwyg_focus'. + * + * @private + * @param {Object} [options] + */ + _onFocus: function (options) { + this.trigger_up('wysiwyg_focus', options); + }, + /** + * trigger_up 'wysiwyg_blur'. + * + * @private + * @param {Object} [options] + */ + _onBlur: function (options) { + this.trigger_up('wysiwyg_blur', options); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQueryEvent} ev + */ + _onMouseEnter: function (ev) { + if (this._isFocused && !this._mouseInEditor && this._isEditorContent(ev.target)) { + this._mouseInEditor = true; + } + }, + /** + * @private + * @param {jQueryEvent} ev + */ + _onMouseLeave: function (ev) { + if (this._isFocused && this._mouseInEditor) { + this._mouseInEditor = null; + } + }, + /** + * @private + * @param {jQueryEvent} ev + */ + _onMouseMove: function (ev) { + if (this._mouseInEditor === null) { + this._mouseInEditor = !!this._isEditorContent(ev.target); + } + }, + /** + * @private + * @param {jQueryEvent} ev + */ + _onMouseDown: function (ev) { + var self = this; + if (this._isEditorContent(ev.target)) { + setTimeout(function () { + if (!self._editableHasFocus && !self._isEditorContent(document.activeElement)) { + self._summernote.layoutInfo.editable.focus(); + } + if (!self._isFocused) { + self._isFocused = true; + self._onFocus(); + } + }); + } else if (this._isFocused) { + this._isFocused = false; + this._onBlur(); + } + }, + /** + * @private + * @param {jQueryEvent} ev + */ + _onBlurEditable: function (ev) { + var self = this; + this._editableHasFocus = false; + if (!this._isFocused) { + return; + } + if (!this._justFocused && !this._mouseInEditor) { + if (this._isFocused) { + this._isFocused = false; + this._onBlur(); + } + } else if (!this._forceEditableFocus) { + this._forceEditableFocus = true; + setTimeout(function () { + if (!self._isEditorContent(document.activeElement)) { + self._summernote.layoutInfo.editable.focus(); + } + self._forceEditableFocus = false; // prevent stack size exceeded. + }); + } else { + this._mouseInEditor = null; + } + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onWysiwygBlur: function (ev) { + if (ev.target === this) { + return; + } + ev.stopPropagation(); + this._isFocused = false; + this._forceEditableFocus = false; + this._mouseInEditor = false; + this._summernote.disable(); + this.$target.focus(); + setTimeout(this._summernote.enable.bind(this._summernote)); + this._onBlur(ev.data); + }, + /** + * @private + * @param {jQueryEvent} ev + */ + _onFocusEditable: function (ev) { + var self = this; + this._editableHasFocus = true; + this._justFocused = true; + setTimeout(function () { + self._justFocused = true; + }); + }, + /** + * trigger_up 'wysiwyg_change' + * + * @private + */ + _onChange: function () { + var html = this._summernote.code(); + if (this.hints.length) { + var hints = []; + _.each(this.hints, function (hint) { + if (html.indexOf('@' + hint.name) !== -1) { + hints.push(hint); + } + }); + this.hints = hints; + } + + this._dirty = true; + this.trigger_up('wysiwyg_change', { + html: html, + hints: this.hints, + attachments: this.attachments, + }); + }, + /** + * trigger_up 'wysiwyg_attachment' when add an image found in the view. + * + * This method is called when an image is uploaded by the media dialog and returns the + * object attachment as recorded in the "ir.attachment" model, via a wysiwyg_attachment event. + * + * For e.g. when sending email, this allows people to add attachments with the content + * editor interface and that they appear in the attachment list. + * The new documents being attached to the email, they will not be erased by the CRON + * when closing the wizard. + * + * @private + */ + _onImageUpload: function (attachments) { + var self = this; + attachments = _.filter(attachments, function (attachment) { + return !_.findWhere(self.attachments, { + id: attachment.id, + }); + }); + if (!attachments.length) { + return; + } + this.attachments = this.attachments.concat(attachments); + + // todo remove image not in the view + + this.trigger_up.bind(this, 'wysiwyg_attachment', this.attachments); + }, + /** + * Called when the carret focuses on another node (focus event, mouse event, or key arrow event) + * from Unbreakable + * + * @private + * @param {Node} node + */ + _onFocusnode: function (node) {}, + /** + * Do not override. + * + * @see _getRecordInfo + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {Object} ev.data.recordInfo + * @param {Function(recordInfo)} ev.data.callback + */ + _onGetRecordInfo: function (ev) { + var data = this._getRecordInfo(ev.data); + data.attachmentIDs = _.pluck(this.attachments, 'id'); + data.user_id = session.uid || session.user_id; + ev.data.callback(data); + }, +}); + +//-------------------------------------------------------------------------- +// Public helper +//-------------------------------------------------------------------------- + +/** + * Load wysiwyg assets if needed. + * + * @see Wysiwyg.createReadyFunction + * @param {Widget} parent + * @returns {$.Promise} + */ +Wysiwyg.prepare = (function () { + var assetsLoaded = false; + var def; + return function prepare(parent) { + if (assetsLoaded) { + return $.when(); + } + if (def) { + return def; + } + def = $.Deferred(); + var timeout = setTimeout(function () { + throw _t("Can't load assets of the wysiwyg editor"); + }, 10000); + var wysiwyg = new Wysiwyg(parent, { + recordInfo: { + context: {}, + } + }); + wysiwyg.attachTo($('<textarea>')).then(function () { + assetsLoaded = true; + clearTimeout(timeout); + wysiwyg.destroy(); + def.resolve(); + }); + return def; + }; +})(); +/** + * @param {Node} node (editable or node inside) + * @returns {Object} + * @returns {Node} sc - start container + * @returns {Number} so - start offset + * @returns {Node} ec - end container + * @returns {Number} eo - end offset + */ +Wysiwyg.getRange = function (node) { + var range = $.summernote.range.create(); + return range && { + sc: range.sc, + so: range.so, + ec: range.ec, + eo: range.eo, + }; +}; +/** + * @param {Node} startNode + * @param {Number} startOffset + * @param {Node} endNode + * @param {Number} endOffset + */ +Wysiwyg.setRange = function (startNode, startOffset, endNode, endOffset) { + $(startNode).focus(); + if (endNode) { + $.summernote.range.create(startNode, startOffset, endNode, endOffset).select(); + } else { + $.summernote.range.create(startNode, startOffset).select(); + } + // trigger for Unbreakable + $(startNode.tagName ? startNode : startNode.parentNode).trigger('wysiwyg.range'); +}; +/** + * @param {Node} node - dom node + * @param {Object} [options] + * @param {Boolean} options.begin move the range to the beginning of the first node. + * @param {Boolean} options.end move the range to the end of the last node. + */ +Wysiwyg.setRangeFromNode = function (node, options) { + var last = node; + while (last.lastChild) { + last = last.lastChild; + } + var first = node; + while (first.firstChild) { + first = first.firstChild; + } + + if (options && options.begin && !options.end) { + Wysiwyg.setRange(first, 0); + } else if (options && !options.begin && options.end) { + Wysiwyg.setRange(last, last.textContent.length); + } else { + Wysiwyg.setRange(first, 0, last, last.tagName ? last.childNodes.length : last.textContent.length); + } +}; + +//-------------------------------------------------------------------------- +// jQuery extensions +//-------------------------------------------------------------------------- + +$.extend($.expr[':'], { + o_editable: function (node, i, m) { + while (node) { + if (node.attributes) { + var className = _.isString(node.className) && node.className || ''; + if ( + className.indexOf('o_not_editable') !== -1 || + (node.attributes.contenteditable && + node.attributes.contenteditable.value !== 'true' && + className.indexOf('o_fake_not_editable') === -1) + ) { + return false; + } + if ( + className.indexOf('o_editable') !== -1 || + (node.attributes.contenteditable && + node.attributes.contenteditable.value === 'true' && + className.indexOf('o_fake_editable') === -1) + ) { + return true; + } + } + node = node.parentNode; + } + return false; + }, +}); + +return Wysiwyg; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js new file mode 100644 index 00000000000..7bfe76e3d24 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js @@ -0,0 +1,261 @@ +odoo.define('web_editor.wysiwyg.iframe', function (require) { +'use strict'; + +var Wysiwyg = require('web_editor.wysiwyg'); +var ajax = require('web.ajax'); + + +var _fnSummernoteMaster = $.fn.summernote; +var _summernoteMaster = $.summernote; +$.fn.summernote = function () { + var summernote = this[0].ownerDocument.defaultView._fnSummenoteSlave || _fnSummernoteMaster; + return summernote.apply(this, arguments); +}; + +/** + * Add option (inIframe) to load Wysiwyg in an iframe. + **/ +Wysiwyg.include({ + /** + * Add options to load Wysiwyg in an iframe. + * + * @override + * @param {boolean} options.inIframe + **/ + init: function (parent, options) { + this._super.apply(this, arguments); + if (this.options.inIframe) { + if (!this.options.iframeCssAssets) { + this.options.iframeCssAssets = 'web_editor.wysiwyg_iframe_css_assets'; + } + this._onUpdateIframeId = 'onLoad_' + this.id; + } + }, + /** + * Load assets to inject into iframe. + * + * @override + **/ + willStart: function () { + if (!this.options.inIframe) { + return this._super(); + } + if (this.options.iframeCssAssets) { + this.defAsset = ajax.loadAsset(this.options.iframeCssAssets); + } else { + this.defAsset = $.when({cssLibs: [], cssContents: []}); + } + this.$target = this.$el; + return this.defAsset + .then(this._loadIframe.bind(this)) + .then(this._super.bind(this)).then(function () { + var _summernoteMaster = $.summernote; + var _summernoteSlave = this.$iframe[0].contentWindow._summernoteSlave; + _summernoteSlave.options = _.extend({}, _summernoteMaster.options, {modules: _summernoteSlave.options.modules}); + this._enableBootstrapInIframe(); + }.bind(this)); + }, + /** + * @override + */ + destroy: function () { + if (!this.options.inIframe) { + return this._super(); + } + $(document.body).off('.' + this.id); + + this.$target.insertBefore(this.$iframe); + + delete window.top[this._onUpdateIframeId]; + if (this.$iframeTarget) { + this.$iframeTarget.remove(); + } + if (this.$iframe) { + this.$iframe.remove(); + } + this._super(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Change fullsreen feature. + * + * @override + * @returns {Object} modules list to load + */ + _getPlugins: function () { + var self = this; + var plugins = this._super(); + plugins.fullscreen = plugins.fullscreen.extend({ + toggle: function () { + if (!self.$iframe) { + return this._super(); + } + self.$iframe.toggleClass('o_fullscreen'); + self.$iframe.contents().find('body').toggleClass('o_fullscreen'); + }, + isFullscreen: function () { + if (!self.$iframe) { + return this._super(); + } + return self.$iframe.hasClass('o_fullscreen'); + }, + }); + return plugins; + }, + /** + * This method is called after the iframe is loaded with the editor. This is + * to activate the bootstrap features that out of the iframe would launch + * automatically when changing the dom. + * + * @private + */ + _enableBootstrapInIframe: function () { + var body = this.$iframe[0].contentWindow.document.body; + var $toolbarButtons = this._summernote.layoutInfo.toolbar.find('[data-toggle="dropdown"]').dropdown({ + boundary: body, + }); + + function hideDrowpdown() { + var $expended = $toolbarButtons.filter('[aria-expanded="true"]').parent(); + $expended.children().removeAttr('aria-expanded').removeClass('show'); + $expended.removeClass('show'); + } + $(body).on('mouseup.' + this.id, hideDrowpdown); + $(document.body).on('click.' + this.id, hideDrowpdown); + }, + /** + * Create iframe, inject css and create a link with the content, + * then inject the target inside. + * + * @private + * @returns {Promise} + */ + _loadIframe: function () { + this.$iframe = $('<iframe class="wysiwyg_iframe">').css({ + 'min-height': '400px', + width: '100%' + }); + var avoidDoubleLoad = 0; // this bug only appears on some configurations. + + // resolve deferred on load + + var def = $.Deferred(); + this.$iframe.data('load-def', def); // for unit test + window.top[this._onUpdateIframeId] = function (_avoidDoubleLoad) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg iframe double load detected'); + return; + } + delete window.top[this._onUpdateIframeId]; + var $iframeTarget = this.$iframe.contents().find('#iframe_target'); + $iframeTarget.append(this.$target); + def.resolve(); + }.bind(this); + + // inject content in iframe + + this.$iframe.on('load', function onLoad (ev) { + var _avoidDoubleLoad = ++avoidDoubleLoad; + this.defAsset.then(function (asset) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg immediate iframe double load detected'); + return; + } + var cwindow = this.$iframe[0].contentWindow; + cwindow.document + .open("text/html", "replace") + .write( + '<head>' + + '<meta charset="utf-8">' + + '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' + + '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' + + _.map(asset.cssLibs, function (cssLib) { + return '<link type="text/css" rel="stylesheet" href="' + cssLib + '"/>'; + }).join('\n') + '\n' + + _.map(asset.cssContents, function (cssContent) { + return '<style type="text/css">' + cssContent + '</style>'; + }).join('\n') + '\n' + + _.map(asset.jsContents, function (jsContent) { + if (jsContent.indexOf('<inline asset>') !== -1) { + return '<script type="text/javascript">' + jsContent + '</script>'; + } + }).join('\n') + '\n' + + '</head>\n' + + '<body class="o_in_iframe">\n' + + '<div id="iframe_target" style="height: calc(100vh - 6px);"></div>\n' + + '<script type="text/javascript">' + + 'window.$ = window.jQuery = window.top.jQuery;' + + 'var _summernoteMaster = $.summernote;' + + 'var _fnSummernoteMaster = $.fn.summernote;' + + 'delete $.summernote;' + + 'delete $.fn.summernote;' + + '</script>\n' + + '<script type="text/javascript" src="/web_editor/static/lib/summernote/summernote.js"></script>\n' + + '<script type="text/javascript">' + + 'window._summernoteSlave = $.summernote;' + + 'window._summernoteSlave.iframe = true;' + + 'window._summernoteSlave.lang = _summernoteMaster.lang;' + + 'window._fnSummenoteSlave = $.fn.summernote;' + + '$.summernote = _summernoteMaster;' + + '$.fn.summernote = _fnSummernoteMaster;' + + 'if (window.top.' + this._onUpdateIframeId + ') {' + + 'window.top.' + this._onUpdateIframeId + '(' + _avoidDoubleLoad + ')' + + '}' + + '</script>\n' + + '</body>'); + }.bind(this)); + }.bind(this)); + + this.$iframe.insertAfter(this.$target); + + return def.promise(); + }, +}); + +//-------------------------------------------------------------------------- +// Public helper +//-------------------------------------------------------------------------- + +/** + * Get the current range from Summernote. + * + * @param {Node} [DOM] + * @returns {Object} + * @returns {Node} sc - start container + * @returns {Number} so - start offset + * @returns {Node} ec - end container + * @returns {Number} eo - end offset +*/ +Wysiwyg.getRange = function (DOM) { + var summernote = (DOM.defaultView || DOM.ownerDocument.defaultView)._summernoteSlave || _summernoteMaster; + var range = summernote.range.create(); + return range && { + sc: range.sc, + so: range.so, + ec: range.ec, + eo: range.eo, + }; +}; +/** + * @param {Node} sc - start container + * @param {Number} so - start offset + * @param {Node} ec - end container + * @param {Number} eo - end offset +*/ +Wysiwyg.setRange = function (sc, so, ec, eo) { + var summernote = sc.ownerDocument.defaultView._summernoteSlave || _summernoteMaster; + $(sc).focus(); + if (ec) { + summernote.range.create(sc, so, ec, eo).select(); + } else { + summernote.range.create(sc, so).select(); + } + // trigger for Unbreakable + $(sc.tagName ? sc : sc.parentNode).trigger('wysiwyg.range'); +}; + +}); diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js index fe8876468b5..bcc3688a118 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js @@ -5,6 +5,7 @@ var core = require('web.core'); var dom = require('web.dom'); var Widget = require('web.Widget'); var options = require('web_editor.snippets.options'); +var Wysiwyg = require('web_editor.wysiwyg'); var _t = core._t; @@ -24,6 +25,7 @@ var SnippetEditor = Widget.extend({ 'click .oe_snippet_parent': '_onParentButtonClick', 'click .oe_snippet_clone': '_onCloneClick', 'click .oe_snippet_remove': '_onRemoveClick', + 'mousedown .oe_options [data-toggle="dropdown"]:first': '_onOpenCusomize', }, custom_events: { cover_update: '_onCoverUpdate', @@ -35,9 +37,15 @@ var SnippetEditor = Widget.extend({ * @param {Widget} parent * @param {Element} target * @param templateOptions + * @param {jQuery} $editable + * @param {Object} options */ - init: function (parent, target, templateOptions) { + init: function (parent, target, templateOptions, $editable, options) { this._super.apply(this, arguments); + this.options = options; + this.$editable = $editable; + this.ownerDocument = this.$editable[0].ownerDocument; + this.$body = $(this.ownerDocument.body); this.$target = $(target); this.$target.data('snippet-editor', this); this.templateOptions = templateOptions; @@ -59,13 +67,13 @@ var SnippetEditor = Widget.extend({ defs.push(this._initializeOptions()); // Initialize move/clone/remove buttons - if (!this.$target.parent().is(':o_editable')) { + if (!this.options.isEditableNode(this.$target[0])) { this.$el.find('.oe_snippet_move, .oe_snippet_clone, .oe_snippet_remove').remove(); } else { this.dropped = false; this.$el.draggable({ greedy: true, - appendTo: 'body', + appendTo: this.$body, cursor: 'move', handle: '.oe_snippet_move', cursorAt: { @@ -76,7 +84,7 @@ var SnippetEditor = Widget.extend({ var $clone = $(this).clone().css({width: '24px', height: '24px', border: 0}); $clone.find('.oe_overlay_options >:not(:contains(.oe_snippet_move)), .o_handle').remove(); $clone.find(':not(.glyphicon)').css({position: 'absolute', top: 0, left: 0}); - $clone.appendTo('body').removeClass('d-none'); + $clone.appendTo(self.$body).removeClass('d-none'); return $clone; }, start: _.bind(self._onDragAndDropStart, self), @@ -134,7 +142,7 @@ var SnippetEditor = Widget.extend({ top: offset.top, }); this.$('.o_handles').css('height', this.$target.outerHeight()); - this.$el.toggleClass('o_top_cover', offset.top < 15); + this.$el.toggleClass('o_top_cover', offset.top < this.$editable.offset().top); }, /** * Removes the associated snippet from the DOM and destroys the associated @@ -159,7 +167,6 @@ var SnippetEditor = Widget.extend({ 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); } @@ -181,11 +188,12 @@ var SnippetEditor = Widget.extend({ } // clean editor if they are image or table in deleted content - $('.note-control-selection').hide(); - $('.o_table_handler').remove(); + this.$body.find('.note-control-selection').hide(); + this.$body.find('.o_table_handler').remove(); this.trigger_up('snippet_removed'); this.destroy(); + $parent.trigger('content_changed'); }, /** * Displays/Hides the editor overlay and notifies the associated snippet @@ -195,6 +203,9 @@ var SnippetEditor = Widget.extend({ * @param {boolean} focus - true to display, false to hide */ toggleFocus: function (focus) { + if (!this.$el) { + return; + } var do_action = (focus ? _do_action_focus : _do_action_blur); // Attach own and parent options on the current overlay @@ -222,6 +233,10 @@ var SnippetEditor = Widget.extend({ this.cover(); this.$el.toggleClass('oe_active', !!focus); + if (focus) { + this.trigger_up('snippet_focused'); + } + function _do_action_focus(style, $dest) { style.$el.insertAfter($dest); style.onFocus(); @@ -282,7 +297,8 @@ var SnippetEditor = Widget.extend({ self, val.base_target ? self.$target.find(val.base_target).eq(0) : self.$target, self.$el, - val.data + val.data, + self.options ); self.styles[optionName || _.uniqueId('option')] = option; option.__order = i++; @@ -324,10 +340,11 @@ var SnippetEditor = Widget.extend({ */ _onCloneClick: function (ev) { ev.preventDefault(); + this.trigger_up('cover_will_change'); + 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, @@ -340,6 +357,7 @@ var SnippetEditor = Widget.extend({ }, }); this.trigger_up('snippet_cloned', {$target: $clone}); + $clone.trigger('content_changed'); }, /** * Called when the overlay dimensions/positions should be recomputed. @@ -347,6 +365,7 @@ var SnippetEditor = Widget.extend({ * @private */ _onCoverUpdate: function () { + this.trigger_up('cover_will_change'); this.cover(); }, /** @@ -382,11 +401,11 @@ var SnippetEditor = Widget.extend({ $selectorChildren: $selectorChildren, }); - $('body').addClass('move-important'); + this.$body.addClass('move-important'); - $('.oe_drop_zone').droppable({ + this.$editable.find('.oe_drop_zone').droppable({ over: function () { - $('.oe_drop_zone.hide').removeClass('hide'); + self.$editable.find('.oe_drop_zone.hide').removeClass('hide'); $(this).addClass('hide').first().after(self.$target); self.dropped = true; }, @@ -396,6 +415,8 @@ var SnippetEditor = Widget.extend({ self.dropped = false; }, }); + + this.trigger_up('cover_will_change'); }, /** * Called when the snippet is dropped after being dragged thanks to the @@ -404,24 +425,23 @@ var SnippetEditor = Widget.extend({ * @private */ _onDragAndDropStop: function () { - var self = this; - - $('.oe_drop_zone').droppable('destroy').remove(); + this.$editable.find('.oe_drop_zone').droppable('destroy').remove(); var prev = this.$target.first()[0].previousSibling; var next = this.$target.last()[0].nextSibling; var $parent = this.$target.parent(); - var $clone = $('.oe_drop_clone'); + var $clone = this.$editable.find('.oe_drop_clone'); if (prev === $clone[0]) { prev = $clone[0].previousSibling; } else if (next === $clone[0]) { next = $clone[0].nextSibling; } $clone.after(this.$target); + var $from = $clone.parent(); this.$el.removeClass('d-none'); - $('body').removeClass('move-important'); + this.$body.removeClass('move-important'); $clone.remove(); if (this.dropped) { @@ -438,10 +458,13 @@ var SnippetEditor = Widget.extend({ for (var i in this.styles) { this.styles[i].onMove(); } + + this.$target.trigger('content_changed'); + $from.trigger('content_changed'); } - self.trigger_up('drag_and_drop_stop', { - $snippet: self.$target, + this.trigger_up('drag_and_drop_stop', { + $snippet: this.$target, }); }, /** @@ -452,6 +475,7 @@ var SnippetEditor = Widget.extend({ * @param {OdooEvent} ev */ _onOptionUpdate: function (ev) { + this.trigger_up('cover_will_change'); // If multiple option names are given, we suppose it should not be // propagated to parent editor if (ev.data.optionNames) { @@ -475,6 +499,16 @@ var SnippetEditor = Widget.extend({ } }, /** + * Called when the user opens the customize menu. + * + * @private + * @param {OdooEvent} ev + */ + _onOpenCusomize: function (ev) { + this.trigger_up('request_history_undo_record', {$target: this.$target}); + }, + + /** * Called when the 'parent' button is clicked. * * @private @@ -494,6 +528,7 @@ var SnippetEditor = Widget.extend({ */ _onRemoveClick: function (ev) { ev.preventDefault(); + this.trigger_up('cover_will_change'); this.trigger_up('request_history_undo_record', {$target: this.$target}); this.removeSnippet(); }, @@ -504,6 +539,7 @@ var SnippetEditor = Widget.extend({ */ var SnippetsMenu = Widget.extend({ id: 'oe_snippets', + cacheSnippetTemplate: {}, activeSnippets: [], custom_events: { activate_insertion_zones: '_onActivateInsertionZones', @@ -513,15 +549,34 @@ var SnippetsMenu = Widget.extend({ go_to_parent: '_onGoToParent', remove_snippet: '_onRemoveSnippet', snippet_removed: '_onSnippetRemoved', + reload_snippet_dropzones: '_disableUndroppableSnippets', }, /** + * @param {Widget} parent + * @param {Object} [options] + * @param {string} [options.snippets] + * URL of the snippets template. This URL might have been set + * in the global 'snippets' variable, otherwise this function + * assigns a default one. + * default: 'web_editor.snippets' + * * @constructor */ - init: function (parent, $editable) { + init: function (parent, options) { this._super.apply(this, arguments); + options = options || {}; + this.trigger_up('getRecordInfo', { + recordInfo: options, + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); - this.$editable = $editable; + this.options = options; + if (!this.options.snippets) { + this.options.snippets = 'web_editor.snippets'; + } this.$activeSnippet = false; this.snippetEditors = []; }, @@ -531,12 +586,14 @@ var SnippetsMenu = Widget.extend({ start: function () { var self = this; var defs = [this._super.apply(this, arguments)]; - var $document = $(document); - var $window = $(window); + this.ownerDocument = this.$el[0].ownerDocument; + this.$document = $(this.ownerDocument); + this.window = this.ownerDocument.defaultView; + this.$window = $(this.window); // Fetch snippet templates and compute it - var url = this._getSnippetURL(); - defs.push(this._rpc({route: url}).then(function (html) { + + defs.push(this.loadSnippets().then(function (html) { return self._computeSnippetTemplates(html); })); @@ -547,7 +604,7 @@ var SnippetsMenu = Widget.extend({ // Active snippet editor on click in the page var lastClickedElement; - $document.on('click.snippets_menu', '*', function (ev) { + this.$document.on('click.snippets_menu', '*', function (ev) { var srcElement = ev.srcElement || (ev.originalEvent && (ev.originalEvent.originalTarget || ev.originalEvent.target) || ev.target); if (lastClickedElement === srcElement || !srcElement) { return; @@ -567,27 +624,21 @@ var SnippetsMenu = Widget.extend({ core.bus.on('deactivate_snippet', this, this._onDeactivateSnippet); core.bus.on('snippet_editor_clean_for_save', this, this._onCleanForSaveDemand); - // Some summernote customization - var _isNotBreakable = $.summernote.core.dom.isNotBreakable; - $.summernote.core.dom.isNotBreakable = function (node) { - return _isNotBreakable(node) || $(node).is('div') || globalSelector.is($(node)); - }; - // Adapt overlay covering when the window is resized / content changes var debouncedCoverUpdate = _.debounce(function () { - self._updateCurrentSnippetEditorOverlay(); + self.updateCurrentSnippetEditorOverlay(); }, 200); - $window.on('resize.snippets_menu', debouncedCoverUpdate); - $window.on('content_changed.snippets_menu', debouncedCoverUpdate); + this.$window.on('resize.snippets_menu', debouncedCoverUpdate); + this.$window.on('content_changed.snippets_menu', debouncedCoverUpdate); // On keydown add a class on the active overlay to hide it and show it // again when the mouse moves - $document.on('keydown.snippets_menu', function () { + this.$document.on('keydown.snippets_menu', function () { if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) { self.$activeSnippet.data('snippet-editor').$el.addClass('o_keypress'); } }); - $document.on('mousemove.snippets_menu', function () { + this.$document.on('mousemove.snippets_menu, mousedown.snippets_menu', function () { if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) { self.$activeSnippet.data('snippet-editor').$el.removeClass('o_keypress'); } @@ -595,12 +646,12 @@ var SnippetsMenu = Widget.extend({ // Auto-selects text elements with a specific class and remove this // on text changes - $document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) { + this.$document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) { $(ev.target).selectContent(); }); - $document.on('keyup.snippets_menu', function () { - var r = $.summernote.core.range.create(); - $(r && r.sc).closest('.o_default_snippet_text').removeClass('o_default_snippet_text'); + this.$document.on('keyup.snippets_menu', function () { + var range = Wysiwyg.getRange(this); + $(range && range.sc).closest('.o_default_snippet_text').removeClass('o_default_snippet_text'); }); return $.when.apply($, defs).then(function () { @@ -608,7 +659,7 @@ var SnippetsMenu = Widget.extend({ // menu will take part of the screen width (delayed because of // animation). (TODO wait for real animation end) setTimeout(function () { - $window.trigger('resize'); + self.$window.trigger('resize'); }, 1000); }); }, @@ -617,11 +668,16 @@ var SnippetsMenu = Widget.extend({ */ destroy: function () { this._super.apply(this, arguments); - this.$snippetEditorArea.remove(); - $(window).off('.snippets_menu'); - $(document).off('.snippets_menu'); + if (this.$window) { + this.$snippetEditorArea.remove(); + this.$window.off('.snippets_menu'); + this.$document.off('.snippets_menu'); + } core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet); core.bus.off('snippet_editor_clean_for_save', this, this._onCleanForSaveDemand); + if (this._defLoadSnippets.state() === "pending") { + delete this.cacheSnippetTemplate[this.options.snippets]; + } }, //-------------------------------------------------------------------------- @@ -634,16 +690,68 @@ var SnippetsMenu = Widget.extend({ * - Remove the 'contentEditable' attributes */ cleanForSave: function () { + this._activateSnippet(false); this.trigger_up('ready_to_clean_for_save'); this._destroyEditors(); - this.$editable.find('[contentEditable]') + this.getEditableArea().find('[contentEditable]') .removeAttr('contentEditable') .removeProp('contentEditable'); - this.$editable.find('.o_we_selected_image') + this.getEditableArea().find('.o_we_selected_image') .removeClass('o_we_selected_image'); }, + /** + * Load snippets. + */ + loadSnippets: function () { + if (this.cacheSnippetTemplate[this.options.snippets]) { + this._defLoadSnippets = this.cacheSnippetTemplate[this.options.snippets]; + return this._defLoadSnippets; + } + this._defLoadSnippets = this._rpc({ + model: 'ir.ui.view', + method: 'render_template', + args: [this.options.snippets, {}], + kwargs: { + context: this.options.context, + }, + }); + this.cacheSnippetTemplate[this.options.snippets] = this._defLoadSnippets; + }, + /** + * Sets the instance variables $editor, $body and selectorEditableArea. + * + * @param {JQuery} $editor + * @param {String} selectorEditableArea + */ + setSelectorEditableArea: function ($editor, selectorEditableArea) { + this.selectorEditableArea = selectorEditableArea; + this.$editor = $editor; + this.$body = $editor.closest('body'); + }, + /** + * Get the editable area. + * + * @returns {JQuery} + */ + getEditableArea: function () { + return this.$editor.find(this.selectorEditableArea); + }, + /** + * Updates the cover dimensions of the current snippet editor. + */ + updateCurrentSnippetEditorOverlay: function () { + if (this.$activeSnippet && this.$activeSnippet.data('snippet-editor')) { + this.$activeSnippet.data('snippet-editor').cover(); + } + this.snippetEditors = _.filter(this.snippetEditors, function (snippetEditor) { + if (snippetEditor.$target.closest('body').length) { + return true; + } + snippetEditor.destroy(); + }); + }, //-------------------------------------------------------------------------- // Private @@ -661,6 +769,7 @@ var SnippetsMenu = Widget.extend({ * child */ _activateInsertionZones: function ($selectorSiblings, $selectorChildren) { + var self = this; var zone_template = $('<div/>', { class: 'oe_drop_zone oe_insert', }); @@ -668,8 +777,8 @@ var SnippetsMenu = Widget.extend({ if ($selectorChildren) { $selectorChildren.each(function () { var $zone = $(this); - var css = window.getComputedStyle(this); - var parentCss = window.getComputedStyle($zone.parent()[0]); + var css = self.window.getComputedStyle(this); + var parentCss = self.window.getComputedStyle($zone.parent()[0]); var float = css.float || css.cssFloat; var parentDisplay = parentCss.display; var parentFlex = parentCss.flexDirection; @@ -680,7 +789,7 @@ var SnippetsMenu = Widget.extend({ var test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === 'BR')); if (test) { $drop.addClass('oe_vertical').css({ - height: parseInt(window.getComputedStyle($zone[0]).lineHeight), + height: parseInt(self.window.getComputedStyle($zone[0]).lineHeight), float: 'none', display: 'inline-block', }); @@ -695,7 +804,7 @@ var SnippetsMenu = Widget.extend({ test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === 'BR')); if (test) { $drop.addClass('oe_vertical').css({ - height: parseInt(window.getComputedStyle($zone[0]).lineHeight), + height: parseInt(self.window.getComputedStyle($zone[0]).lineHeight), float: 'none', display: 'inline-block' }); @@ -715,8 +824,8 @@ var SnippetsMenu = Widget.extend({ $selectorSiblings.filter(':not(.oe_drop_zone):not(.oe_drop_clone)').each(function () { var $zone = $(this); var $drop; - var css = window.getComputedStyle(this); - var parentCss = window.getComputedStyle($zone.parent()[0]); + var css = self.window.getComputedStyle(this); + var parentCss = self.window.getComputedStyle($zone.parent()[0]); var float = css.float || css.cssFloat; var parentDisplay = parentCss.display; var parentFlex = parentCss.flexDirection; @@ -742,14 +851,14 @@ var SnippetsMenu = Widget.extend({ var $zones; do { count = 0; - $zones = this.$editable.find('.oe_drop_zone > .oe_drop_zone').remove(); // no recursive zones + $zones = this.getEditableArea().find('.oe_drop_zone > .oe_drop_zone').remove(); // no recursive zones count += $zones.length; $zones.remove(); } while (count > 0); // Cleaning consecutive zone and up zones placed between floating or // inline elements. We do not like these kind of zones. - $zones = this.$editable.find('.oe_drop_zone:not(.oe_vertical)'); + $zones = this.getEditableArea().find('.oe_drop_zone:not(.oe_vertical)'); $zones.each(function () { var zone = $(this); var prev = zone.prev(); @@ -805,6 +914,7 @@ var SnippetsMenu = Widget.extend({ } if ($snippet && $snippet.length) { var self = this; + this.trigger_up('activate_snippet', $snippet); return this._createSnippetEditor($snippet).then(function (editor) { self.$activeSnippet = $snippet; editor.toggleFocus(true); @@ -822,16 +932,6 @@ var SnippetsMenu = Widget.extend({ }); }, /** - * Updates the cover dimensions of the current snippet editor. - * - * @private - */ - _updateCurrentSnippetEditorOverlay: function () { - if (this.$activeSnippet && this.$activeSnippet.data('snippet-editor')) { - this.$activeSnippet.data('snippet-editor').cover(); - } - }, - /** * Calls a given callback 'on' the given snippet and all its child ones if * any (DOM element with options). * @@ -910,7 +1010,7 @@ var SnippetsMenu = Widget.extend({ selectorConditions += ':has(' + target + ')'; } if (!noCheck) { - selectorConditions = ':o_editable' + selectorConditions; + selectorConditions = (this.options.addDropSelector || '') + selectorConditions; } // (Re)join the subselectors @@ -933,7 +1033,7 @@ var SnippetsMenu = Widget.extend({ }; } else { functions.closest = function ($from, parentNode) { - var parents = self.$editable.get(); + var parents = self.getEditableArea().get(); return $from.closest(selector, parentNode).filter(function () { var node = this; while (node.parentNode) { @@ -946,9 +1046,9 @@ var SnippetsMenu = Widget.extend({ }); }; functions.all = isChildren ? function ($from) { - return dom.cssFind($from || self.$editable, selector); + return dom.cssFind($from || self.getEditableArea(), selector); } : function ($from) { - $from = $from || self.$editable; + $from = $from || self.getEditableArea(); return $from.filter(selector).add(dom.cssFind($from, selector)); }; } @@ -1075,7 +1175,6 @@ var SnippetsMenu = Widget.extend({ if (!this.$snippets.length) { this.$el.detach(); } - $('body').toggleClass('editor_has_snippets', this.$snippets.length > 0); // Register the text nodes that needs to be auto-selected on click this._registerDefaultTexts(); @@ -1123,7 +1222,7 @@ var SnippetsMenu = Widget.extend({ } return $.when(def).then(function (parentEditor) { - snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions); + snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions, self.getEditableArea(), self.options); self.snippetEditors.push(snippetEditor); return snippetEditor.appendTo(self.$snippetEditorArea); }).then(function () { @@ -1159,17 +1258,6 @@ var SnippetsMenu = Widget.extend({ }); }, /** - * Returns the URL where to find the snippets template. This URL might have - * been set in the global 'snippetsURL' variable, otherwise this function - * returns a default one. - * - * @private - * @returns {string} - */ - _getSnippetURL: function () { - return odoo.snippetsURL || '/web_editor/snippets'; - }, - /** * Make given snippets be draggable/droppable thanks to their thumbnail. * * @private @@ -1185,8 +1273,7 @@ var SnippetsMenu = Widget.extend({ $snippets.draggable({ greedy: true, helper: 'clone', - zIndex: '1000', - appendTo: 'body', + appendTo: this.$body, cursor: 'move', handle: '.oe_snippet_thumbnail', distance: 30, @@ -1204,12 +1291,10 @@ var SnippetsMenu = Widget.extend({ for (var k in temp) { if ($base_body.is(temp[k].base_selector) && !$base_body.is(temp[k].base_exclude)) { if (temp[k]['drop-near']) { - if (!$selectorSiblings) $selectorSiblings = temp[k]['drop-near'].all(); - else $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all()); + $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all()); } if (temp[k]['drop-in']) { - if (!$selectorChildren) $selectorChildren = temp[k]['drop-in'].all(); - else $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all()); + $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all()); } } } @@ -1224,7 +1309,7 @@ var SnippetsMenu = Widget.extend({ self._activateSnippet(false); self._activateInsertionZones($selectorSiblings, $selectorChildren); - $('.oe_drop_zone').droppable({ + self.getEditableArea().find('.oe_drop_zone').droppable({ over: function () { if (!dropped) { dropped = true; @@ -1240,6 +1325,8 @@ var SnippetsMenu = Widget.extend({ } }, }); + + self.trigger_up('cover_will_change'); }, stop: function (ev, ui) { $toInsert.removeClass('oe_snippet_body'); @@ -1252,7 +1339,7 @@ var SnippetsMenu = Widget.extend({ } } - self.$editable.find('.oe_drop_zone').droppable('destroy').remove(); + self.getEditableArea().find('.oe_drop_zone').droppable('destroy').remove(); if (dropped) { var prev = $toInsert.first()[0].previousSibling; @@ -1273,8 +1360,6 @@ var SnippetsMenu = Widget.extend({ $parent.prepend($toInsert); } - $toInsert.closest('.o_editable').trigger('content_changed'); - var $target = $toInsert; _.defer(function () { @@ -1282,12 +1367,12 @@ var SnippetsMenu = Widget.extend({ self._disableUndroppableSnippets(); self._callForEachChildSnippet($target, function (editor, $snippet) { - _.defer(function () { - editor.buildSnippet(); - }); + editor.buildSnippet(); }).then(function () { - $target.closest('.o_editable').trigger('content_changed'); - self._activateSnippet($target); + $target.trigger('content_changed'); + if ($target.closest('body').length) { // can be destroyed (eg in test) + self._activateSnippet($target); + } }); }); } else { diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js index 97d3777b95f..802311f09d0 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js @@ -4,8 +4,9 @@ odoo.define('web_editor.snippets.options', function (require) { var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); -var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); -var weWidgets = require('web_editor.widget'); +var weWidgets = require('wysiwyg.widgets'); +var FontPlugin = require('web_editor.wysiwyg.plugin.font'); +var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog'); var qweb = core.qweb; var _t = core._t; @@ -18,10 +19,12 @@ var _t = core._t; var SnippetOption = Widget.extend({ events: { 'mouseenter': '_onLinkEnter', - 'mouseenter .dropdown-item': '_onLinkEnter', + 'mouseenter a': '_onLinkEnter', 'click': '_onLinkClick', + 'click a': '_onLinkClick', 'mouseleave': '_onMouseleave', - 'mouseleave .dropdown-item': '_onMouseleave', + 'mouseleave a': '_onMouseleave', + 'mouseleave .dropdown-menu': '_onMouseleave', }, /** * When editing a snippet, its options are shown alongside the ones of its @@ -38,9 +41,11 @@ var SnippetOption = Widget.extend({ * * @constructor */ - init: function (parent, $target, $overlay, data) { + init: function (parent, $target, $overlay, data, options) { this._super.apply(this, arguments); + this.options = options; this.$target = $target; + this.ownerDocument = this.$target[0].ownerDocument; this.$overlay = $overlay; this.data = data; this.__methodNames = []; @@ -240,6 +245,7 @@ var SnippetOption = Widget.extend({ if (!previewMode) { this._reset(); this.trigger_up('request_history_undo_record', {$target: this.$target}); + this.$target.trigger('content_changed'); } // Search for methods (data-...) (i.e. data-toggle-class) on the @@ -353,7 +359,7 @@ var SnippetOption = Widget.extend({ */ _onLinkClick: function (ev) { var $opt = $(ev.target).closest('.dropdown-item'); - if (!$opt.length || !$opt.is(':hasData')) { + if (ev.isDefaultPrevented() || !$opt.length || !$opt.is(':hasData')) { return; } @@ -440,7 +446,7 @@ registry.sizing = SnippetOption.extend({ var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); var cursor = $handle.css('cursor') + '-important'; - var $body = $(document.body); + var $body = $(this.ownerDocument.body); $body.addClass(cursor); var xy = ev['page' + XY]; @@ -647,12 +653,19 @@ registry.colorpicker = SnippetOption.extend({ } if (!this.$el.find('.colorpicker').length) { - // TODO remove old UI's code that survived - var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); - var $clpicker = $(qweb.render('web_editor.colorpicker')); + // Add common colors to palettes if not excluded + var fontPlugin = new FontPlugin({ + layoutInfo: { + editable: $('<div/>'), + editingArea: $('<div/>'), + }, + options: {}, + }); - _.each($clpicker.find('.o_colorpicker_section'), function (elem) { - $(elem).prepend("<div class='text-muted mt8'>" + elem.dataset.display + "</div>"); + var $clpicker = fontPlugin.createPalette('backColor').find('.note-color-palette'); // don't use custom color + $clpicker.find('.note-color-reset').remove(); + $clpicker.find('h6').each(function () { + $(this).replaceWith($('<div class="mt8"/>').text($(this).text())); }); // Retrieve excluded palettes list @@ -662,7 +675,7 @@ registry.colorpicker = SnippetOption.extend({ } // Apply a custom title if specified if (this.data.paletteTitle) { - $pt.find('.note-palette-title').text(this.data.paletteTitle); + $clpicker.find('.note-palette-title').text(this.data.paletteTitle); } // Remove excluded palettes @@ -670,39 +683,29 @@ registry.colorpicker = SnippetOption.extend({ $clpicker.find('[data-name="' + exc + '"]').remove(); }); - // Add common colors to palettes if not excluded - if (!('common' in excluded)) { - var $commonColorSection = $clpicker.find('[data-name="common"]'); - _.each(summernoteCustomColors, function (colorRow, i) { - var $div = $('<div/>', {class: 'clearfix'}).appendTo($commonColorSection); - if (i === 0) { - // Ignore the summernote gray palette and use ours - return; - } - _.each(colorRow, function (color) { - $div.append('<button class="o_custom_color" style="background-color: ' + color + '" />'); - }); - }); - } - + var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); $pt.find('.o_colorpicker_section_tabs').append($clpicker); this.$el.find('.dropdown-menu').append($pt); } + var bgColor = ColorpickerDialog.formatColor(self.$target.css('background-color')); var classes = []; this.$el.find('.colorpicker button').each(function () { var $color = $(this); var color = $color.data('color'); - if (!color) { - return; - } - - $color.addClass('bg-' + color); - var className = self.colorPrefix + color; - if (self.$target.hasClass(className)) { - $color.addClass('selected'); + if (color) { + $color.addClass('bg-' + color); + var className = self.colorPrefix + color; + if (self.$target.hasClass(className)) { + $color.addClass('selected'); + } + classes.push(className); + } else { + color = $color.data('value'); + if (bgColor === ColorpickerDialog.formatColor(color)) { + $color.addClass('selected'); + } } - classes.push(className); }); this.classes = classes.join(' '); @@ -736,6 +739,9 @@ registry.colorpicker = SnippetOption.extend({ var color = $(ev.currentTarget).data('color'); if (color) { this.$target.addClass(this.colorPrefix + color); + } else if ($(ev.currentTarget).data('value')) { + color = $(ev.currentTarget).data('value'); + this.$target.css('background-color', color); } else if ($(ev.target).hasClass('o_custom_color')) { this.$target .removeClass(this.classes) @@ -828,7 +834,7 @@ registry.background = SnippetOption.extend({ */ chooseImage: function (previewMode, value, $opt) { // Put fake image in the DOM, edit it and use it as background-image - var $image = $('<img/>', {class: 'd-none', src: value}).appendTo(this.$target); + var $image = $('<img/>', {class: 'd-none', src: value === 'true' ? '' : value}).appendTo(this.$target); var $editable = this.$target.closest('.o_editable'); var _editor = new weWidgets.MediaDialog(this, { @@ -836,10 +842,11 @@ registry.background = SnippetOption.extend({ firstFilters: ['background'], res_model: $editable.data('oe-model'), res_id: $editable.data('oe-id'), - }, null, $image[0]).open(); + }, $image[0]).open(); _editor.on('save', this, function () { this._setCustomBackground($image.attr('src')); + this.$target.trigger('content_changed'); }); _editor.on('closed', this, function () { $image.remove(); @@ -894,7 +901,8 @@ registry.background = SnippetOption.extend({ if (value === undefined) { value = this.$target.css('background-image'); } - return value.replace(/url\(['"]*|['"]*\)|^none$/g, ''); + var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g; + return value && value.replace(srcValueWrapper, '') || ''; }, /** * @override @@ -1159,6 +1167,11 @@ registry.many2one = SnippetOption.extend({ */ start: function () { var self = this; + this.trigger_up('getRecordInfo', _.extend(this.options, { + callback: function (recordInfo) { + _.defaults(self.options, recordInfo); + }, + })); this.Model = this.$target.data('oe-many2one-model'); this.ID = +this.$target.data('oe-many2one-id'); @@ -1191,6 +1204,7 @@ registry.many2one = SnippetOption.extend({ this.$search.find('input') .focus() .on('keyup', function (e) { + self.$overlay.removeClass('o_keypress'); self._findExisting($(this).val()); }); @@ -1257,6 +1271,7 @@ registry.many2one = SnippetOption.extend({ kwargs: { order: [{name: 'name', asc: false}], limit: 5, + context: this.options.context, }, }).then(function (result) { self.$search.siblings().remove(); @@ -1295,6 +1310,7 @@ registry.many2one = SnippetOption.extend({ args: [[self.ID]], kwargs: { options: options, + context: self.options.context, }, }).then(function (html) { $node.html(html); diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js b/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js new file mode 100644 index 00000000000..d1dc29ca7b1 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js @@ -0,0 +1,260 @@ +odoo.define('web_editor.wysiwyg.snippets', function (require) { +'use strict'; + +var Wysiwyg = require('web_editor.wysiwyg'); +var snippetsEditor = require('web_editor.snippet.editor'); + +Wysiwyg.include({ + events: _.extend({}, Wysiwyg.prototype.events, { + 'content_changed .o_editable': '_onContentChange', + 'content_changed .note-editable': '_onContentChange', + }), + custom_events: _.extend({}, Wysiwyg.prototype.custom_events, { + request_history_undo_record: '_onHistoryUndoRecordRequest', + cover_will_change: '_onCoverWillChange', + snippet_cloned: '_onSnippetCloned', + snippet_dropped: '_onSnippetDropped', + snippet_focused: '_onSnippetFocused', + }), + + selectorEditableArea: '.note-editable', + + init: function (parent, options) { + this._super.apply(this, arguments); + + options = _.clone(this.options); + if (!options.snippets) { + return; + } + if (options.snippets === true) { + options.snippets = 'web_editor.snippets'; + } + options.isUnbreakableNode = this.isUnbreakableNode.bind(this); + options.isEditableNode = this.isEditableNode.bind(this); + this.snippets = new snippetsEditor.Class(this, options); + }, + /** + * Preload snippets. + * + * @override + **/ + willStart: function () { + if (this.snippets) { + this.snippets.loadSnippets(); // don't use the deferred + } + return this._super(); + }, + /** + * Add options (snippets) to load snippet building block + * snippets can by url begin with '/' or an view xml_id. + * + * @override + * @param {string} [options.snippets] + */ + start: function () { + var self = this; + return this._super().then(function () { + if (!self.snippets) { + return; + } + var $editable = $(self._summernote.layoutInfo.editable); + self.snippets.setSelectorEditableArea(self.$el, self.selectorEditableArea); + self.snippets.insertBefore(self.$el).then(function () { + self.$el.before(self.snippets.$el); + var $wrap = $('<div class="o_wrap_editable_snippets"/>'); + $wrap.on('scroll', function (event) { + self._summernote.triggerEvent('scroll', event); + }); + var $contents = self.snippets.$el.siblings('#oe_manipulators') + .addClass('o_wysiwyg_to_remove').attr('contentEditable', false); + $wrap.append($contents); + $editable.before($wrap); + $wrap.append($editable); + setTimeout(function () { // add a set timeout for the transition + self.snippets.$el.addClass('o_loaded'); + self.$el.addClass('o_snippets_loaded'); + self.trigger_up('snippets_loaded', self.snippets.$el); + }); + }); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + isUnbreakableNode: function (node) { + if (!this.snippets) { + return this._super(node); + } + return this._super(node) || node.tagName === 'DIV' || snippetsEditor.globalSelector.is($(node)); + }, + /** + * @override + */ + isEditableNode: function (node) { + if (!this._super(node)) { + return false; + } + if (this.snippets && node.tagName === 'DIV' && !node.contentEditable) { + if ($(node).parent('.row').length) { + return true; + } + if (!_.find(node.childNodes, function (node) { + var char = this.context.invoke('HelperPlugin.getRegex', 'char'); + return node.tagName !== 'DIV' && (node.tagName || node.textContent.match(char)); + })) { + return false; + } + } + return true; + }, + /** + * @override + */ + save: function () { + if (!this.snippets) { + return this._super(); + } + var defs = []; + this.trigger_up('ready_to_save', {defs: defs}); + return $.when.apply($, defs) + .then(this.snippets.cleanForSave.bind(this.snippets)) + .then(this._super.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQueryEvent} ev + */ + _onContentChange: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (this.snippets) { + this.addHistoryStep(); + } + this._onChange(); + }, + /** + * @private + * @override + */ + _onChange: function () { + if (this.snippets) { + this.snippets.updateCurrentSnippetEditorOverlay(); + } + this._super.apply(this, arguments); + }, + /** + * Called when an element askes to record an history undo -> records it. + * + * @private + */ + _onHistoryUndoRecordRequest: function () { + this.addHistoryStep(); + }, + /** + * Triggered when the user click on the snippet to drag&drop, remove or clone + * (hide the popover) + * + * @private + */ + _onCoverWillChange: function () { + var context = this._summernote; + var target = context.invoke('editor.restoreTarget', target); + context.invoke('MediaPlugin.hidePopovers'); + }, + /** + * Triggered when a snippet is cloned in the editable area + * + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {Object} ev.data.$target + */ + _onSnippetCloned: function (ev) { + this._summernote.invoke('UnbreakablePlugin.secureArea', ev.data.$target[0]); + }, + /** + * Triggered when a snippet is dropped in the editable area + * + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {Object} ev.data.$target + */ + _onSnippetDropped: function (ev) { + this._summernote.invoke('UnbreakablePlugin.secureArea', ev.data.$target[0]); + }, + /** + * Triggered when the user focuses on a snippet: hides the popover. + * + * @private + */ + _onSnippetFocused: function () { + var context = this._summernote; + var target = context.invoke('editor.restoreTarget', target); + context.invoke('MediaPlugin.hidePopovers'); + context.invoke('MediaPlugin.update', target); + }, +}); + +//-------------------------------------------------------------------------- +// jQuery extensions +//-------------------------------------------------------------------------- + +$.fn.extend({ + /** + * Set the range and focus at the start of the first found node. + * + * @returns {JQuery} + */ + focusIn: function () { + if (this.length) { + Wysiwyg.setRangeFromNode(this[0], {begin: true}); + $(this).trigger('mouseup'); + } + return this; + }, + /** + * Set the range and focus at the end of the first found node. + * + * @returns {JQuery} + */ + focusInEnd: function () { + if (this.length) { + Wysiwyg.setRangeFromNode(this[0], {end: true}); + $(this).trigger('mouseup'); + } + return this; + }, + /** + * Set the range and focus on a selection, accounting for zero-width spaces. + * + * @returns {JQuery} + */ + selectContent: function () { + if (this.length) { + Wysiwyg.setRangeFromNode(this[0]); + var range = $.summernote.range.create(); + if (!range.sc.tagName && range.so === 0 && range.sc.textContent[range.so] === '\u200B') { + range.so += 1; + } + if (!range.ec.tagName && range.eo === range.ec.textContent.length && range.ec.textContent[range.eo - 1] === '\u200B') { + range.eo -= 1; + } + range.select(); + $(this).trigger('mouseup'); + } + return this; + }, +}); + +}); diff --git a/addons/web_editor/static/src/scss/web_editor.backend.scss b/addons/web_editor/static/src/scss/web_editor.backend.scss index 7ddbaeaa7ce..83062313b20 100644 --- a/addons/web_editor/static/src/scss/web_editor.backend.scss +++ b/addons/web_editor/static/src/scss/web_editor.backend.scss @@ -1,23 +1,24 @@ -.oe_form_field_html_text { +.oe_form_field_html { + position: relative; + .note-editable { min-height: 180px; - font: initial !important; + font: inherit !important; font-family: inherit !important; - line-height: initial !important; - color: initial !important; + line-height: inherit !important; + color: inherit !important; + overflow: visible; + p, div { font-family: 'Lucida Grande', Helvetica, Verdana, Arial, sans-serif; font-size: 13px; } - a, a:hover { - color: initial; - } - ul > li > p { - margin: 0px; - } } - .o_readonly { - min-height: 1em; + ul > li > p, p { + margin: 0px; + } + > iframe { + display: block; width: 100%; margin: 0; padding: 0; @@ -26,66 +27,15 @@ ul > li > p { margin: 0px; } + min-height: 300px; + min-height: -webkit-calc(100vh - 170px); + min-height: calc(100vh - 170px); } -} -.oe_form_field_html iframe { - display: block; - width: 100%; - border: 0; - padding: 0; - margin: 0; - min-height: 300px; - min-height: -webkit-calc(100vh - 170px); - min-height: calc(100vh - 170px); -} -.o_field_widgetTextHtml_fullscreen { - .oe_form_field_html.o_form_fullscreen_ancestor iframe { - position: absolute !important; - left: 0 !important; - right: 0 !important; - top: 0 !important; - bottom: 0 !important; - width: 100% !important; - min-height: 100% !important; - z-index: 1001 !important; - border: 0; - } - * { - display: none; - } - .o_form_fullscreen_ancestor { - display: block !important; - position: static !important; - top: 0 !important; - left: 0 !important; - width: auto !important; - overflow: hidden !important; - transform: none !important; - } -} - -.openerp .oe_form .oe_form_field_html_translate textarea { - min-width: 60px; - width: 100%; - color: #4c4c4c; - font: normal 13px "Lucida Grande", Helvetica, Verdana, Arial, sans-serif; -} -.openerp .oe_form_readonly .oe_form .oe_form_field_html_translate textarea { - border: 0; - background: #fff; -} -.openerp .oe_form_editable .oe_form .oe_form_field_html_translate { - > button { - float: right; - margin: -6px -3px 0 0; - } - > .note-editable { - border: 1px solid #ccc; - min-height: 22px; - border-radius: 3px; + .rounded { + border-radius: .25rem !important; } - .note-editable [contentEditable='true']:hover { - box-shadow: #DDCC33 0 0 5px 2px inset; + table.table.table-bordered { + table-layout: fixed; } } diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss index c082e823cc0..6f83dc28524 100644 --- a/addons/web_editor/static/src/scss/web_editor.common.scss +++ b/addons/web_editor/static/src/scss/web_editor.common.scss @@ -39,6 +39,46 @@ html, body { } } +// List +ul.o_checklist { + list-style: none; + + >li { + position: relative; + margin-left: $o-checklist-margin-left; + + &::before { + content: ''; + position: absolute; + left: - $o-checklist-margin-left; + display: block; + height: $o-checklist-before-size; + width: $o-checklist-before-size; + margin-top: 4px; + border: 1px solid; + text-align: center; + cursor: pointer; + } + &.o_checked { + text-decoration: line-through; + &::after { + content: "✓"; + position: absolute; + left: - ($o-checklist-margin-left - $o-checklist-checkmark-width); + top: +1px; + } + } + } +} +ul > li.o_indent { + margin-left: 0; + list-style: none; + &::before { + content: none; + } +} + + // Medias img.shadow { box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2); @@ -55,6 +95,9 @@ img.padding-large, .img.padding-large, span.fa.padding-large, iframe.padding-lar img.padding-xl, .img.padding-xl, span.fa.padding-xl, iframe.padding-xl { padding: 32px; } +img.ml-auto, img.mx-auto { + display: block; +} .fa-6x { font-size: 6em; @@ -80,6 +123,7 @@ div.media_iframe_video { text-align: center; position: relative; overflow: hidden; + min-width: 100px; iframe { width: 100%; @@ -109,7 +153,7 @@ div.media_iframe_video { } .css_editable_mode_display { - @include o-position-absolute; + @include o-position-absolute(0,0,0,0); width: 100%; height: 100%; display: none; diff --git a/addons/web_editor/static/src/scss/web_editor.ui.scss b/addons/web_editor/static/src/scss/web_editor.ui.scss deleted file mode 100644 index 06e56ee657f..00000000000 --- a/addons/web_editor/static/src/scss/web_editor.ui.scss +++ /dev/null @@ -1,1616 +0,0 @@ -// Mixins for buttons with alpha bg-color -@mixin o-alpha-button-preview { - position: relative; - background-image: url('/web_editor/static/src/img/transparent.png'); - background-size: 10px auto; - z-index: 1; - - // Place an overlay that inherit the button's bg-color. - &::before { - content: ""; - @include o-position-absolute(0, 0, 0, 0); - z-index: -1; - background-color: inherit; - } -} - -// TOP BAR (EDIT) -#web_editor-top-edit { - @include o-position-absolute(0, 0, auto, 0); - position: fixed; - height: $o-navbar-height; - z-index: $zindex-modal - 9; - - background-color: rgba(0,0,0,0); - transition: background-color 400ms $o-we-md-ease 0s; - font-family: $o-we-font-family; - - form.navbar-form { - height: 100%; - z-index: 1060; - margin: 0; - padding: 0; - @include o-position-absolute($right: -$o-we-sidebar-width); - transition: right 0.4s $o-we-md-ease 0s; - border-left: 1px solid $o-we-color-divider; - background-color: inherit; - - .btn-group { - height: 100%; - } - - .btn { - height: 100%; - margin: 0; - padding: 10px; - line-height: 1.2; - font-size: 13px; - font-family: $o-we-font-family; - - transition: all 0.3s ease 0s; - border: none; - border-radius: 0; - - .fa { - margin-right: $grid-gutter-width/4; - &.fa-times { - color: $o-we-color-danger; - } - } - - &.btn-primary { - @include button-variant($o-brand-odoo, $o-brand-odoo); - } - &.btn-secondary { - @include button-variant($o-we-color-dark, $o-we-color-dark); - } - - &:focus, &:active, &:focus:active { - outline: none; - } - } - .dropdown-menu { - left: auto; - right: 0; - } - } -} - -// Translations -.oe_translate_examples li { - margin: 10px; - padding: 4px; -} -html[lang] > body.editor_enable [data-oe-translation-state] { - background: rgba($o-we-content-to-translate-color, 0.5)!important; - - &[data-oe-translation-state="translated"] { - background: rgba($o-we-translated-content-color, 0.5)!important; - } - &.o_dirty { - background: rgba($o-we-translated-content-color, 0.25)!important; - } -} - -// SNIPPET PANEL -#oe_snippets { - @include o-w-preserve-btn; - - display: flex; - flex-flow: column nowrap; - @include o-position-absolute(0, auto, 0, -$o-we-sidebar-width); - position: fixed; - width: $o-we-sidebar-width; - z-index: $zindex-modal - 9; - - font-family: Roboto, $font-family-sans-serif; - border-right: 1px solid $o-we-color-divider; - transition: left 400ms $o-we-md-ease 0s; - background-image: linear-gradient(45deg, $o-we-color-normal, darken($o-we-color-normal, 10%)); - box-shadow: 0px 10px 10px -10px black inset; - - #snippets_menu { - flex: 0 0 auto; - height: $o-navbar-height; - line-height: $o-navbar-height; - margin: 0; - padding: 0; - background: $o-we-color-dark; - text-align: center; - font-weight: normal; - font-size: 17px; - color: white; - font-family: $o-we-font-family; - } - - #o_scroll { - height: 100%; - overflow: auto; - - .o_panel_header { - color: #999999; - display: flex; - align-items: center; - justify-content: center; - padding: 3%; - color: white; - margin-bottom: 3%; - - i { - margin-right: 5px; - } - } - .o_panel_body { - &:after { - content: " "; - display: table; - clear: both; - } - - .oe_snippet { - float: left; - width: 45.5%; - margin: 0 0 6% 3%; - box-shadow: none; - background-color: transparent; - user-select: none; - - cursor: pointer; - cursor: copy; - cursor: grab; - - .oe_snippet_thumbnail { - .oe_snippet_thumbnail_img { - border: none; - } - .oe_snippet_thumbnail_title { - border: none; - padding: 5px 0; - } - - &:hover .oe_snippet_thumbnail_img { - transition: transform 200ms ease 0s; - transform: scale(1.05); - } - } - - &.o_disabled { - .oe_snippet_thumbnail { - background-color: rgba(255, 0, 0, 0.75); - } - .oe_snippet_thumbnail_img { - opacity: 0.8; - } - } - - &.o_snippet_install { - .oe_snippet_thumbnail_img { - opacity: 0.5; - } - .btn.o_install_btn { - display: none; - @include o-position-absolute(16px, 4px, auto, 4px); - } - &:hover .btn.o_install_btn { - display: block; - } - } - - &:nth-child(2n+1) { - clear: left; - } - } - } - } -} -.oe_snippet { // No root because can be drag and drop (and the helper is in the body) - position: relative; - width: 100px; - background-color: $o-we-color-normal; - - > :not(.oe_snippet_thumbnail) { - display: none!important; - } - - .oe_snippet_thumbnail { - width: 100%; - - .oe_snippet_thumbnail_img { - width: 100%; - height: 0; - border: 1px solid $o-we-color-normal; - padding-bottom: 75%; - background-size: cover; - background-position: center center; - text-align: center; - overflow: hidden; - } - img.oe_snippet_thumbnail_img { - height: auto; - padding-bottom: 0; - } - .oe_snippet_thumbnail_title { - display: block; - border: 1px solid $o-we-color-dark; - padding: 5px; - font-size: 12px; - font-weight: 300; - text-shadow: none; - color: $o-we-color-text-light; - } - &:hover .oe_snippet_thumbnail_title { - color: white; - } - } -} - -// DROPZONES -@keyframes dropZoneInsert { - to { - background-color: rgba($o-brand-odoo, 0.2); - } -} -#wrapwrap .oe_drop_zone { - background-color: rgba($o-brand-odoo, 0.05); - animation: dropZoneInsert 1s linear 0s infinite alternate; - - &.oe_insert { - z-index: 999; - - border: $o-we-dropzone-border; - border-top: none; - border-bottom: none; - - position: relative; - width: 100%; - height: $o-we-dropzone-size; - margin: (-$o-we-dropzone-size/2) 0; - - &:after { - content: ""; - display: block; - width: 100%; - height: 50%; - border-bottom: $o-we-dropzone-border; - box-sizing: content-box; - } - - &.oe_vertical { - border: 2px dashed $o-brand-odoo; - border-left: none; - border-right: none; - - width: $o-we-dropzone-size; - float: left; - margin: 0 (-$o-we-dropzone-size/2); - - &:after { - width: 50%; - height: 100%; - border-bottom: none; - border-right: $o-we-dropzone-border; - } - } - } -} - -// MANIPULATORS -#oe_manipulators { - position: relative; - - // SNIPPET MANIPULATORS - .oe_overlay { - @include o-position-absolute; - z-index: 1002; - display: none; - height: 0; - background: transparent; - text-align: center; - transition: opacity 400ms linear 0s; - &.o_keypress { - opacity: 0; - } - &.oe_active { - display: block; - } - - // OVERLAY OPTIONS - > .oe_overlay_options { - font-family: $o-we-font-family; - @include o-position-absolute($bottom: 0, $left: 0); - width: 250px; // the parent oe_overlay has a width equal to the snippet width, the 250px is there to force the options to stay on one line without overriding bootstrap .btn-group - text-align: left; - - > .btn-group { - white-space: nowrap; - - .btn { - display: inline-block; - width: $o-we-overlay-option-size; - height: $o-we-overlay-option-size; - padding: 0; - border: 1px solid $o-we-color-dark; - line-height: $o-we-overlay-option-size; - font-size: 11px; - border-radius: 0; - font-weight: normal; - - transition: all 400ms ease 0s; - color: $o-we-color-text-light; - background-color: $o-we-color-light; - &:hover { - color: white; - background-color: lighten($o-we-color-dark, 10%); - - > .fa { - color: white; - } - } - - &.oe_snippet_remove { - color: white; - background-color: $o-we-color-danger; - border-color: darken($o-we-color-danger, 20%); - - > .fa { - color: white; - } - - &:hover { - background-color: darken($o-we-color-danger, 20%); - } - } - &.oe_snippet_parent > i { - transform: scaleX(-1); - font-size: 12px; - } - - > .fa { - color: $o-we-color-text-light; - } - } - - // CUSTOMIZE MENU BUTTON - > .oe_options { - .btn { - width: auto; - padding: 0 25px 0 5px; - background-color: $o-we-color-dark; - text-transform: uppercase; - &:hover, &:active, &:focus { - background-color: lighten($o-we-color-dark, 5%); - border-color: lighten($o-we-color-dark, 10%); - color: white; - } - - &:before, &:after { - content: ""; - width: 7px; - height: 2px; - background-color: $o-we-color-text-light; - @include o-position-absolute(46%); - transition: all 0.3s ease 0s; - } - &:before { - right: 9px; - transform: rotate(45deg); - } - &:after { - right: 5px; - transform: rotate(-45deg); - } - } - // Open menu - &.show .btn { - // background-color: $o-we-color-light; - &:active, &:focus, &:active:focus { - box-shadow: none; - } - &:before, &:after { - width: 9px; - } - &:before { - right: 10px; - transform: translateX(5px) rotate(-45deg); - } - &:after { - transform: rotate(45deg); - } - } - - // CUSTOMIZE MENU - .dropdown-menu { - margin: -1px 0 0 0; - padding: 0; - border: 1px solid $o-we-color-dark; - border-radius: 0; - background-color: $o-we-color-light; - - .dropdown-item { - position: relative; - color: $o-we-color-text-light; - font-weight: normal; - font-size: 12px; - padding: 5px 40px 5px 10px; - - &:hover { - background-color: fade-out($o-we-color-dark, 0.5); - color: white; - background: transparent; - cursor: pointer; - } - &.active { - color: white; - background-color: fade-out($o-we-color-dark, 0.5); - &:before { - @include o-position-absolute($top: 5px, $right: 5px); - content: "\f00c"; - font-family: "FontAwesome"; - color: $o-brand-primary; - } - } - > i { - width: 15px; - margin-right: 10px; - text-align: center; - } - } - - .dropdown-submenu { - position: relative; - &::before { - @include o-position-absolute($top: 12px, $right: 10px); - @include o-caret-right(4px); - border-left-color: $o-we-color-text-light; - } - &:hover > .dropdown-menu { - display: block; - left: 100%; - top: 0; - &.o_open_to_left { - left: auto; - right: 100%; - } - } - } - - .dropdown-divider { - display: block; - height: 1px; - border-top: 1px solid #666; - margin: 0; - } - - .dropdown-header { - color: wheat; - font-size: 11px; - margin-top: 2px; - text-transform: uppercase; - &:hover { - background-color: initial; - } - - &.o_main_header { - padding-left: 10px; - } - &.o_parent_editor_header { - position: relative; - color: wheat; - font-weight: 400; - font-size: 10px; - margin: 10px 0 0 10px; - text-transform: uppercase; - padding-bottom: .25em; - &::before { - content: "\f148"; - @include o-position-absolute($top: 5px, $left: 4px); - font-family: FontAwesome; - transform: scaleX(-1); - } - ~ .dropdown-submenu .dropdown-item, - ~ .dropdown-item { - font-size: 11px; - opacity: 0.7; - &:hover { - opacity: 1; - } - } - ~ .dropdown-submenu::before { - @include o-position-absolute($top: 10px, $right: 10px); - } - } - } - } - } - } - } - - &.o_top_cover > .oe_overlay_options { - bottom: auto; - top: $o-we-handle-border-width; - right: $o-we-handle-border-width; - } - - // HANDLES - > .o_handles { - @include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0); - - &:hover > .o_handle { - background-color: rgba($o-we-handles-accent-color, 0.05); - } - - > .o_handle { - - position: relative; - border: 0 solid $o-we-handles-accent-color; - transition: background 300ms ease 0s; - - &:hover, &.o_active { - background-color: rgba($o-we-handles-accent-color, 0.2); - - &:before { - content: ''; - @include o-position-absolute(0, $left:50%); - width: 1px; - height: 100%; - margin-left: -1px; - background-color: rgba($o-we-handles-accent-color, 0.5); - } - - &.w:before { - @include o-position-absolute(50%, $left: 0); - width: 100%; - height: 1px; - } - - &:after { - border-color: darken($o-we-handles-accent-color, 10%); - background-color: darken($o-we-handles-accent-color, 20%); - } - } - - &:after { - display: block; - width: $o-we-handles-btn-size; - height: $o-we-handles-btn-size; - border: solid 1px darken($o-we-handles-accent-color, 10%); - line-height: $o-we-handles-btn-size - 2; - font-size: 14px; - font-family: FontAwesome; - background-color: darken($o-we-handles-accent-color, 20%); - color: white; - } - &.w:after, &.e:after { - content: "\f07e"; - } - &.s:after, &.n:after { - content: "\f07d"; - } - - &.o_handle_start { - &.w:after, &.e:after { - content: '\f061'; - } - &.n:after, &.s:after { - content: '\f063'; - } - } - &.o_handle_end { - &.w:after, &.e:after { - content: '\f060'; - } - &.n:after, &.s:after { - content: '\f062'; - } - } - - &.w { - @include o-position-absolute($o-we-handles-offset-to-hide, auto, -$o-we-handles-offset-to-hide, 0); - width: $o-we-handle-edge-size; - border-width: $o-we-handle-border-width; - border-right-width: 0; - cursor: e-resize; - - &:after { - @include o-position-absolute($top: 50%, $left: 40%); - margin-top: -$o-we-handles-btn-size/2; - } - } - &.e { - @include o-position-absolute($o-we-handles-offset-to-hide, 0, -$o-we-handles-offset-to-hide, auto); - width: $o-we-handle-edge-size; - border-right-width: $o-we-handle-border-width; - cursor: w-resize; - - &:after { - @include o-position-absolute($top: 50%, $right: 40%); - margin-top: -$o-we-handles-btn-size/2; - } - } - &.n { - @include o-position-absolute($o-we-handles-offset-to-hide, 0, auto, 0); - border-top-width: $o-we-handle-border-width; - cursor: ns-resize; - - &:after { - @include o-position-absolute($left: 50%, $top: 40%); - margin-left: -$o-we-handles-btn-size/2; - } - } - &.s { - @include o-position-absolute(auto, 0, -$o-we-handles-offset-to-hide, 0); - border-bottom-width: $o-we-handle-border-width; - cursor: ns-resize; - - &:after { - @include o-position-absolute($left: 50%, $bottom: 40%); - margin-left: -$o-we-handles-btn-size/2; - } - } - - &.readonly { - cursor: auto!important; - - &:after { - display: none!important; - } - &:hover { - opacity: 0.5; - } - } - } - } - } -} - -.s-resize-important * { - cursor: s-resize !important; -} -.n-resize-important * { - cursor: n-resize !important; -} -.e-resize-important * { - cursor: e-resize !important; -} -.w-resize-important * { - cursor: w-resize !important; -} -.move-important * { - cursor: move !important; -} - -// NOTE EDITOR -.note-popover .popover { - height: $o-navbar-height; - top: 0 !important; - left: 0 !important; - padding: 0; - margin: 0 0 0 $o-we-sidebar-width; - background-color: $o-we-color-dark; - border-radius: 0; - border-width: 0 1px; - width: auto; - text-align: center; - box-shadow: none; - - .popover-body { - height: $o-navbar-height; - white-space: nowrap; - font-family: $o-we-font-family; - - h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { - font-family: $o-we-font-family; - } - - .btn { - height: $o-navbar-height; - border: none; - border-radius: 0; - padding: $btn-padding-y $btn-padding-y; // force this because of themes - background-color: $o-we-color-dark; - color: $o-we-color-text-normal; - border-top: 2px solid transparent; - transition: all 0.3s ease 0s; - - > .fa { - color: $o-we-color-text-normal; - } - - &:hover { - border-top: 2px solid $o-we-color-text-normal; - color: $o-we-color-text-light; - - > .fa { - color: $o-we-color-text-light; - } - } - - .o_image_alt { - @include o-text-overflow(inline-block); - max-width: 100px; - } - - &.active { - border-top: 2px solid white; - background-color: $o-we-color-dark; - color: $o-we-color-text-light; - box-shadow: inset 0 3px 10px rgba(0, 0, 0, 0.5); - - > .fa { - color: $o-we-color-text-light; - } - } - } - > .btn-group { - margin-top: -2px; - - &.show, .btn-group.show { - .dropdown-toggle { - border-top: 2px solid white; - background-color: $o-we-color-darker; - color: white; - box-shadow: none; - - > .fa, > span { - color: white; - } - } - } - } - .dropdown-menu { - margin: 0; - border-radius: 0; - background-color: $o-we-color-darker; - color: $o-we-color-text-normal; - white-space: normal; - - .dropdown-item { - width: 100%; - color: $o-we-color-text-light; - - > .fa { - color: $o-we-color-text-light; - } - - &:hover { - background-color: $o-we-tab-active-bg; - color: white; - - > .fa { - color: white; - } - } - } - } - .note-para .dropdown-menu { - min-width: 228px; - } - - .note-color-palette .note-color-row, .note-custom-color-palette .note-color-row { - height: auto!important; - - &::after { - content: ""; - display: table; - clear: both; - } - - .note-color-btn { - float: left; - height: 20px; - width: 20px; - margin: 1px 1px 0 0; - border: 1px solid black; - @include o-alpha-button-preview; - - &.o_small { - width: 16px; - height: 16px; - margin: 3px 3px 2px 2px; - border-radius: 50%; - } - - &.o_clear { - clear: both; - } - - &.o_btn_transparent { - display: none; - } - } - } - } -} - -.note-popover .popover, .note-editor { - .dropdown-menu { - .dropdown-item { - > i { - visibility: hidden; - } - &.checked > i { - visibility: visible; - } - } - } - - .note-color { - .note-back-color-preview, .note-fore-color-preview { - .dropdown-toggle { - padding: 0px 7px; - height: 35px; - width: auto; - border-top: none !important; - border-bottom: 3px solid; - &:after { - display: none; - } - } - } - .o_foreground_toggle > button:before { - font-family: FontAwesome; - content: '\f1fc'; - } - .o_background_toggle > button:before { - font-family: FontAwesome; - content: '\f0c3'; - } - .dropdown-menu { - min-width: 180px!important; - .note-custom-color { - cursor: pointer; - border-radius: 5px; - &:hover { - background-color: gray('400'); - color: black - } - } - } - } -} - -table.colorpicker { - width: 100%; - td { - padding: 0 2px 2px 0; - > button { - width: 100%; - border: 1px solid black; - padding: 0; - &::after { - content: "A"; - font-weight: bold; - vertical-align: sub; - } - } - } -} -.colorpicker { - background-color: $o-we-color-dark!important; - - .o_colorpicker_sections { - border: 1px solid darken($o-we-color-dark, 12%); - font-size: 14px; - - .o_colorpicker_section_tabs { - width: 225px; - height: 100%; - } - } - .o_colorpicker_section { - &:after { - content: ""; - display: table; - clear: both; - } - - button { - float: left; - height: 25px; - width: 25px; - margin: 1px 1px 0 2px; - border: 1px solid black; - @include o-alpha-button-preview; - - &.o_small { - width: 23px; - height: 23px; - margin: 2px; - border-radius: 50%; - - + :not(.o_small) { - clear: both; - } - } - - &:hover, &.selected { - box-shadow: 0px 0px 2px 2px $o-we-color-light; - } - - &.selected:before { - content: "\f00c"; - font-family: "FontAwesome"; - padding-top: 1px; - color: theme-color('success'); - } - - &[data-event="foreColor"] { - background-color: $o-we-color-normal; - &:before { - background-color: rgba(white, 0.3); - } - } - - &.o_btn_transparent::before { - background-color: transparent; - } - } - } - .note-palette-title { - padding: 10px 0px 10px 10px; - color: $o-we-color-text-light; - font-weight: bold; - } - .palette-reset { - @include o-position-absolute(0, 0); - margin: 0; - padding: 5px 10px 0; - .note-color-reset { - font-size: 20px!important; - color: desaturate(rgba($o-we-color-danger, 0.6), 40%); - margin: 1px 0 0; - padding: 0; - cursor: pointer; - &:hover { - background: transparent!important; - color: $o-we-color-danger; - } - } - } -} - -// ENTER IN EDIT MODE -body.editor_enable { - padding-top: $o-navbar-height!important; - - #web_editor-top-edit { - background-color: $o-we-color-dark; - form.navbar-form { - right: 0; - } - } - - &.editor_has_snippets { - transition: padding-left 400ms $o-we-md-ease 0s; - padding-left: $o-we-sidebar-width!important; - - #oe_snippets { - left: 0; - } - } - .btn { - -webkit-user-select: none; - } -} - -#web_editor_inside_iframe { - background-color: $o-brand-secondary; - - #wrapwrap { - position: relative; - display: table; - table-layout: fixed; - width: 100%; - height: 100%; - - > * { - display: table-row; - } - > main { - height: 100%; - - #editable_area { - position: relative; - width: 100%; - height: 100%; - background-color: white; - } - } - } -} - -// Animations -@keyframes fadeInDownSmall { - 0% { - opacity: 0; - transform: translate(0, -5px); - } - 100% { - opacity: 1; - transform: translate(0, 0); - } -} -@keyframes fadeInOut { - 0% { - opacity: 0; - } - 25% { - opacity: 1; - } - 75% { - opacity: 1; - } - 100% { - opacity: 0; - } -} -@keyframes inputHighlighter { - from { background: $o-brand-primary; } - to { width: 0; background: transparent; } -} - -// INPUTS -.o_switch { - display: flex; - align-items: center; - font-weight: normal; - cursor: pointer; - - > input { - display: none; - - + span { - background-color: $o-we-switch-inactive-color; - box-shadow: inset 0 0 0px 1px darken($o-we-switch-inactive-color,10%); - border-radius: 100rem; - height: $o-we-switch-size; - width: $o-we-switch-size * 1.8; - margin-right: 0.5em; - display: inline-block; - transition: all 0.3s $o-we-md-ease; - - &:after { - content: ""; - background: $o-we-color-paper; - display: block; - width: $o-we-switch-size - 0.2; - height: $o-we-switch-size - 0.2; - margin-top: 0.1ex; - margin-left: 0.1ex; - border-radius: 100rem; - transition: all 0.3s $o-we-md-ease; - box-shadow: 0 1px 1px darken($o-we-switch-inactive-color,35%), inset 0 0 0 1px lighten($o-we-switch-inactive-color,10%); - } - } - - &:checked + span { - box-shadow: none; - background-image: linear-gradient(0deg, $o-we-color-success, darken($o-we-color-success, 10%)); - - &:after { - margin-left: ($o-we-switch-size*1.8 - $o-we-switch-size) + 0.1; - } - } - } - &.o_switch_danger { - > input { - &:not(:checked) + span { - box-shadow: none; - background-image: linear-gradient(0deg, lighten(theme-color('danger'), 5%), darken(theme-color('danger'), 5%)); - } - } - } -} - -.dropdown-menu label .o_switch { - margin: 0; - padding: 2px 0; -} - -.text-input-group { - position: relative; - margin-bottom: 45px; - - input { - font-size: 18px; - padding: 10px 10px 10px 5px; - display: block; - width: 300px; - border: none; - border-bottom: 1px solid #757575; - } - input:focus { outline: none; } - - /* LABEL ======================================= */ - label { - color: #999; - font-size: 18px; - font-weight: normal; - @include o-position-absolute($top: 10px, $left: 5px); - pointer-events: none; - transition: 0.2s ease all; - } - - /* active state */ - input:focus ~ label, input:valid ~ label { - top: -20px; - font-size: 14px; - color: #5264AE; - } - - /* BOTTOM BARS ================================= */ - .bar { position: relative; display: block; width: 300px; } - .bar:before, .bar:after { - content: ''; - height: 2px; - width: 0; - bottom: 1px; - @include o-position-absolute; - background: #5264AE; - transition: 0.2s ease all; - } - .bar:before { - left: 50%; - } - .bar:after { - right: 50%; - } - - /* active state */ - input:focus ~ .bar:before, input:focus ~ .bar:after { - width: 50%; - } - - /* HIGHLIGHTER ================================== */ - .highlight { - @include o-position-absolute($top: 25%, $left: 0); - height: 60%; - width: 100px; - pointer-events: none; - opacity: 0.5; - } - - /* active state */ - input:focus ~ .highlight { - animation: inputHighlighter 0.3s ease; - } -} - -// DRAG&DROP ANIMATIONS -.oe_snippet_body { - opacity: 0; - animation: fadeInDownSmall 700ms forwards; -} - -// ACE EDITOR -.o_ace_view_editor { - background: $o-we-ace-color; - color: white; - display: flex; - flex-flow: column nowrap; - opacity: 0.97; - - .o_ace_view_editor_title { - flex: 0 0 auto; - display: flex; - align-items: center; - padding: $grid-gutter-width/4; - - > .o_ace_type_switcher > button::after { - @include o-caret-down; - margin-left: 4px; - } - - > * { - flex: 0 0 auto; - margin: 0 $grid-gutter-width/4; - - &.o_include_option { - display: flex; - align-items: center; - font-size: 11px; - - > .custom-control { - margin-right: $grid-gutter-width/4; - } - } - - &.o_res_list { - flex: 1 1 auto; - min-width: 60px; - } - } - } - #ace-view-id { - flex: 0 0 auto; - padding: $grid-gutter-width/4 $grid-gutter-width/2; - background-color: lighten($o-we-ace-color, 10%); - } - #ace-view-editor { - @mixin ace-line-error-mixin { - content: ""; - z-index: 1000; - display: block; - background-color: theme-color('danger'); - opacity: 0.5; - pointer-events: none; - } - - height: 70%; // in case flex is not supported - flex: 1 1 auto; - .ace_gutter { - display: block!important; // display even with aria-hidden - cursor: ew-resize; - - .ace_gutter-cell.o_error { - position: relative; - &::after { - @include o-position-absolute(-100%, 0, -100%, 0); - @include ace-line-error-mixin; - } - } - } - .ace_resize_bar { - @include o-position-absolute($right: 0); - width: 25px; - height: 100%; - cursor: ew-resize; - } - .ace_scroller.o_error::after { - @include o-position-absolute(0, auto, 0, 0); - width: 3px; - @include ace-line-error-mixin; - } - } -} -.o_ace_select2_dropdown { - width: auto!important; - padding-top: 4px; - font-family: monospace!important; - - > .select2-results { - max-height: none; - max-height: 70vh; - - .select2-result-label { - padding-top: 1px; - padding-bottom: 2px; - - >.o_ace_select2_result { - padding: 0; - font-size: 12px; - white-space: nowrap; - } - } - } -} - -// MODALS -body .modal { - // SELECT MEDIA - .o_select_media_dialog { - max-width: 80%; - - .modal-body { - .tab-pane { - min-height: 300px; - } - - .o_existing_attachment_cell { - $o-border-image: 2px; - - .o_existing_attachment_remove { - @include o-position-absolute($o-border-image, 15px + $o-border-image); - padding: 3px 5px 5px 5px; - background-color: rgba(white, 0.8); - text-shadow: 0 0 1px white; - border-radius: 0 0 0 2px; - opacity: 0; - cursor: pointer; - - &:hover { - background-color: white; - color: $o-we-color-danger; - } - } - .o_image { - border: $o-border-image solid transparent; - cursor: pointer; - transition: opacity 400ms ease 0s; - - &.o_webimage { - width: 100%; - height: auto; - background-image: none!important; - } - - &.o_image_loading { - opacity: 0; - } - } - &.o_selected .o_image { - border-color: $o-brand-odoo; - box-shadow: 0px 0px 2px 2px $o-brand-secondary; - } - &:hover .o_existing_attachment_remove { - opacity: 1; - } - } - .font-icons-icons { - > span { - text-align: center; - font-size: 22px; - margin: 5px; - width: 50px; - height: 50px; - padding: 15px; - cursor: pointer; - - &.o_selected { - cursor: auto; - background-color: $o-we-color-paper; - box-shadow: 0px 0px 0px 1px $o-we-color-success; - } - } - } - - #editor-media-image, #editor-media-document { - .o_we_url_input { - width: 300px; - } - } - - // VIDEO TAB - #editor-media-video { - .o_video_dialog_form { - #o_video_form_group { - position: relative; - width: 100%; - - > textarea { - width: 100%; - min-height: 95px; - padding-bottom: 25px; - overflow-y: scroll; - } - - .o_validate_feedback { - @include o-position-absolute(auto, 10px, 10px); - - > .fa { - display: none; - } - } - &.o_has_error .o_validate_feedback > .fa-exclamation-triangle { - display: block; - } - &.o_has_success .o_validate_feedback > .fa-check { - display: block; - } - } - } - - #video-preview { - position: relative; - - @include o-we-preview-box(); - border: none; - - .media_iframe_video { - width: 100%; - } - - .o_video_dialog_iframe { - @include o-we-preview-content; - max-width: 100%; - max-height: 100%; - - &.alert { - animation: fadeInDownSmall 700ms forwards; - margin: 0 auto; - } - } - } - } - } - } - - // BACKGROUND IMAGE OPTIONS - .o_bg_img_opt_modal .o_bg_img_opt { - margin: 15px 0; - - .help-control { - @include o-position-absolute(15px, 15px); - } - .help { - margin: 15px 0; - - .simulator { - position: relative; - float: left; - margin-right: 15px; - width: 120px; - height: 100px; - .bg { - @include o-position-absolute(0, 0, 0, 0); - border: 1px dotted #888787; - background-image: url(/web/image); - background-position: center center; - background-size: cover; - } - .el { - @include o-position-absolute(10px, 0, 10px, 0); - border: 1px solid #222; - } - &.contain_bg { - .bg { - @include o-position-absolute(0, 12px, 20px, 12px); - } - .el { - @include o-position-absolute(0, 0, 20px, 0); - } - } - } - + * { - clear: left; - } - } - - .o_bg_img_opt_cover_edition { - @include o-we-preview-box; - padding: 0 25px 25px 25px; - - h6 { - color: white; - font-weight: bold; - } - .o_bg_img_opt_object { - position: relative; - background: white; - min-height: 10px; - @include o-we-preview-content; - - > img { - cursor: crosshair; - border-top: 1px solid #5A5A5A; - border-bottom: 1px solid #111; - } - &:hover .o_focus_point:before { - opacity: 0.5; - } - } - .o_bg_img_opt_ui_info { - animation: fadeInOut 2s ease forwards; - @include o-position-absolute($bottom: 1px, $right: 0); - display: block; - padding: 3px 13px; - background-color: rgba(255, 255, 255, 0.8); - color: #333; - text-align: center; - font-weight: bold; - pointer-events: none; - span { - font-weight: normal; - } - .o_x { - margin-right: 10px - } - } - .grid { - @include o-position-absolute($top: 33.33%, $left: 0); - display: block; - width: 100%; - height: 1px; - background: fade-out(lighten($o-brand-primary, 30%), 0.5); - pointer-events: none; - &.grid-2 { - top: 66.66% - } - &.grid-3 { - top: 0; - left: 33.33%; - width: 1px; - height: 100% - } - &.grid-4 { - top: 0; - left: 66.66%; - width: 1px; - height: 100% - } - } - .o_focus_point { - @include o-position-absolute($top: 0, $left: 0); - width: 30px; - height: 30px; - border: 2px solid white; - border-radius: 100%; - margin-top: -15px; - margin-left: -15px; - box-shadow: 0 0 1px #333; - pointer-events: none; - &.o_with_transition { - transition: all 0.2s ease 0s; - } - &:before { - pointer-events: none; - content: ""; - display: block; - width: 100px; - height: 100px; - margin-top: -37px; - margin-left: -37px; - border: 1px solid #EAEAEA; - background: rgba(247, 76, 76, 0); - border-radius: 100em; - opacity: 1; - box-shadow: 0 0 0 100em rgba(0, 0, 0, 0.33); - transition: opacity 0.2s ease 0s; - } - } - } - } - - // LINK EDITOR DIALOG COLOR SELECTOR - .o_link_dialog { - .o_link_dialog_color > .o_link_dialog_color_item { - position: relative; - width: 30px; - height: 30px; - padding: 0; // Important for themes - border: 2px solid rgba(0, 0, 0, 0.25); - border-radius: 50%; - - input:checked + i::before { - content: "\f00c"; - @include o-position-absolute($top: 6px, $left: 5px); - } - - &.btn-link { - padding: 3px 9px; - - > span { - text-decoration: underline; - } - } - } - .o_link_dialog_preview { - border-left: 1px solid gray('200'); - } - } - // Crop Dialog - .o_crop_image_dialog .o_crop_area { - height: 350px; - .cropper-point { - background-color: white; - transition: all 400ms $o-we-md-ease 0s; - transform: scale(1.5, 1.5); - border-radius: 10px; - box-shadow: 0 0 1px 1px rgba(23, 23, 23, 0.5); - opacity: 1; - &:hover { - transform: scale(2.5, 2.5); - } - } - .cropper-line { - background: black; - } - .cropper-view-box { - outline: 2px solid white; - } - } -} - - -// Highlight selected image/icon -%o-we-selected-image { - $o-selected-image-color: rgba(150, 150, 220, 0.3); - background: $o-selected-image-color; - outline: 3px solid $o-selected-image-color; -} -img.o_we_selected_image { - @extend %o-we-selected-image; -} -.fa.o_we_selected_image::before { - @extend %o-we-selected-image; -} diff --git a/addons/web_editor/static/src/scss/web_editor.variables.scss b/addons/web_editor/static/src/scss/web_editor.variables.scss index e556d6e5e4e..a66e3516c77 100644 --- a/addons/web_editor/static/src/scss/web_editor.variables.scss +++ b/addons/web_editor/static/src/scss/web_editor.variables.scss @@ -12,6 +12,7 @@ $o-we-color-normal: #50545d !default; $o-we-color-paper: #fcfcfc !default; $o-we-color-text-normal: #999999 !default; $o-we-color-text-light: #d4d5d7 !default; +$o-we-color-text-lighter: #ebecee !default; $o-we-color-danger: #e6586c !default; $o-we-color-warning: #f0ad4e !default; $o-we-color-success: #40ad67 !default; @@ -42,6 +43,11 @@ $o-we-ace-color: #2F3129 !default; $o-we-switch-size: 2ex !default; $o-we-switch-inactive-color: $o-we-color-paper !default; +// Checklist +$o-checklist-margin-left: 20px; +$o-checklist-checkmark-width: 2px; +$o-checklist-before-size: 13px; + // ============ 'Preview' component Mixins =============== // Create a "preview" work-area to edit/customize specific elements diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss new file mode 100644 index 00000000000..9a61755acf8 --- /dev/null +++ b/addons/web_editor/static/src/scss/wysiwyg.scss @@ -0,0 +1,522 @@ +// NOTE EDITOR + +.note-editor.note-frame { + background-color: inherit; + + .note-editing-area .note-editable { + background-color: transparent !important; + color: inherit; + padding-left: 0; + padding-right: 0; + } + + &.fullscreen { + background-color: #fff; + } +} + +.note-popover.popover { + z-index: $o-wysiwyg-zindex; + height: auto; + + .popover-content { + padding-bottom: 0; + max-width: 334px; + + >note-btn-group { + white-space: nowrap; + } + } +} + +.note-editor .note-toolbar { + white-space: nowrap; + z-index: $o-wysiwyg-zindex + 10; + height: $o-wysiwyg-toolbar-height; +} + +.note-popover.popover .popover-content, +.note-editor .note-toolbar { + font-family: $o-wysiwyg-font-family; + background-color: $o-wysiwyg-bg-color; + + h1, + h2, + h3, + h4, + h5, + h6, + .h1, + .h2, + .h3, + .h4, + .h5, + .h6 { + font-family: $o-wysiwyg-font-family; + } + + .btn { + height: $o-wysiwyg-toolbar-height; + border: none; + border-radius: 0; + background-color: inherit; + color: inherit; + border-top: 2px solid transparent; + transition: all 0.3s ease 0s; + + >.fa { + color: inherit; + } + + &:hover { + border-top: 2px solid inherit; + color: inherit; + + >.fa { + color: inherit; + } + } + + .o_image_alt { + @include o-text-overflow(inline-block); + max-width: 100px; + } + + &.active { + border-top: 2px solid white; + background-color: inherit; + color: inherit; + box-shadow: inset 0 3px 10px rgba(0, 0, 0, 0.5); + + >.fa { + color: inherit; + } + } + + &.o_disabled { + opacity: 0.5; + pointer-events: none; + } + } + + .note-btn-group { + margin: 0; + } + + >.btn-group { + margin-top: 0; + + &.show, + .btn-group.show { + .dropdown-toggle { + border-top: 2px solid white; + background-color: $o-wysiwyg-bg-color-dark; + box-shadow: none; + + >.fa, + >span { + color: inherit; + } + } + } + } + + .dropdown-menu { + margin: 0; + border-radius: 0; + background-color: $o-wysiwyg-bg-color; + color: inherit; + white-space: normal; + + .dropdown-item { + width: 100%; + color: inherit; + + >.fa { + color: inherit; + } + + &:hover { + background-color: $o-wysiwyg-bg-color-dark; + color: inherit; + + >.fa { + color: inherit; + } + } + } + } + + .note-para .dropdown-menu { + min-width: 0; + white-space: nowrap; + } + + .note-color { + + .note-back-color-preview, + .note-fore-color-preview { + .dropdown-toggle { + padding: 0px 7px; + height: 35px; + width: auto; + border-top: none; + border-bottom: 3px solid; + + &:after { + display: none; + } + } + } + + .dropdown-menu { + min-width: 180px; + + .note-custom-color { + cursor: pointer; + border-radius: 5px; + + &:hover { + background-color: gray('400'); + color: black + } + } + } + + .dropdown-toggle:after { + display: none; + } + + .note-palette:first-child { + width: 100%; + + .note-color-reset, + .note-custom-color { + width: 162px; + } + + .note-color-reset { + padding: 0 3px !important; + height: 32px; + } + + .note-holder, + .note-color-palette .note-color-row, + .note-custom-color-palette .note-color-row { + height: auto !important; + + &::after { + content: ""; + display: table; + clear: both; + } + + .note-color-btn { + float: left; + height: 20px; + width: 20px; + margin: 1px 1px 0 0; + border: 1px solid black; + cursor: pointer; + @include o-alpha-button-preview; + + &.o_small { + width: 16px; + height: 16px; + margin: 3px 3px 2px 2px; + border-radius: 50%; + } + + &.o_clear { + clear: both; + } + + &.o_btn_transparent { + display: none; + } + } + } + } + } +} + +.note-popover.popover.note-hint-popover .popover-content { + height: auto; +} + +.note-popover, +.note-editor { + .modal .modal-footer p { + display: none; + } +} + +.note-handle { + pointer-events: none; + + .note-control-selection { + z-index: 1; + overflow: hidden; + + .note-control-se { + border-top: 0; + border-left: 0; + background: transparent; + } + } +} + +.o_fake_editable:not([data-oe-model]):focus { + outline: none; +} + +.note-editor.dragover .note-dropzone { + z-index: $o-wysiwyg-zindex + 1; +} + +// MODALS + +body .modal { + + // SELECT MEDIA + .o_select_media_dialog { + max-width: 80%; + + .modal-body { + .tab-pane { + min-height: 300px; + } + + .existing-attachments { + width: 100%; + } + + .o_existing_attachment_cell { + $o-border-image: 2px; + + .o_existing_attachment_remove { + @include o-position-absolute($o-border-image, 15px + $o-border-image); + padding: 3px 5px 5px 5px; + background-color: rgba(white, 0.8); + text-shadow: 0 0 1px white; + border-radius: 0 0 0 2px; + opacity: 0; + cursor: pointer; + + &:hover { + background-color: white; + color: $o-wysiwyg-color-danger; + } + } + + .o_image { + border: $o-border-image solid transparent; + cursor: pointer; + transition: opacity 400ms ease 0s; + + &.o_webimage { + width: 100%; + height: auto; + background-image: none !important; + } + + &.o_image_loading { + opacity: 0; + } + } + + &.o_selected .o_image { + border-color: $o-brand-odoo; + box-shadow: 0px 0px 2px 2px $o-brand-secondary; + } + + &:hover .o_existing_attachment_remove { + opacity: 1; + } + } + + .font-icons-icons { + >span { + text-align: center; + font-size: 22px; + margin: 5px; + width: 50px; + height: 50px; + padding: 15px; + cursor: pointer; + + &.o_selected { + cursor: auto; + background-color: $o-wysiwyg-bg-color-dark; + box-shadow: 0px 0px 0px 1px $o-wysiwyg-color-success; + } + } + } + + #editor-media-image, + #editor-media-document { + .o_we_url_input { + width: 300px; + } + } + + // VIDEO TAB + #editor-media-video { + .o_video_dialog_form { + #o_video_form_group { + position: relative; + width: 100%; + + >textarea { + width: 100%; + min-height: 95px; + padding-bottom: 25px; + overflow-y: scroll; + } + + .o_validate_feedback { + @include o-position-absolute(auto, 10px, 10px); + + >.fa { + display: none; + } + } + + &.o_has_error .o_validate_feedback>.fa-exclamation-triangle { + display: block; + } + + &.o_has_success .o_validate_feedback>.fa-check { + display: block; + } + } + } + + #video-preview { + position: relative; + @include o-we-preview-box(); + border: none; + + .media_iframe_video { + width: 100%; + } + + .o_video_dialog_iframe { + @include o-we-preview-content; + max-width: 100%; + max-height: 100%; + + &.alert { + animation: fadeInDownSmall 700ms forwards; + margin: 0 auto; + } + } + } + } + } + } + + // Crop Dialog + .o_crop_image_dialog .o_crop_area { + height: 350px; + + .cropper-point { + background-color: white; + transition: all 400ms $o-wysiwyg-md-ease 0s; + transform: scale(1.5, 1.5); + border-radius: 10px; + box-shadow: 0 0 1px 1px rgba(23, 23, 23, 0.5); + opacity: 1; + + &:hover { + transform: scale(2.5, 2.5); + } + } + + .cropper-line { + background: black; + } + + .cropper-view-box { + outline: 2px solid white; + } + } + + // LINK EDITOR DIALOG COLOR SELECTOR + .o_link_dialog { + .o_link_dialog_color>.o_link_dialog_color_item { + position: relative; + width: 30px; + height: 30px; + padding: 0; // Important for themes + border: 2px solid rgba(0, 0, 0, 0.25); + border-radius: 50%; + + input:checked+i::before { + content: "\f00c"; + @include o-position-absolute($top: 6px, $left: 5px); + } + + &.btn-link { + padding: 3px 9px; + + >span { + text-decoration: underline; + } + } + } + + .o_link_dialog_preview { + border-left: 1px solid gray('200'); + } + } +} + +// Highlight selected image/icon +%o-we-selected-image { + $o-selected-image-color: rgba(150, 150, 220, 0.3); + background: $o-selected-image-color; + outline: 3px solid $o-selected-image-color; +} + +img.o_we_selected_image { + @extend %o-we-selected-image; +} + +.fa.o_we_selected_image::before { + @extend %o-we-selected-image; +} + +.fa.mx-auto, +img.o_we_custom_image.mx-auto { + display: block; +} + +// ENTER IN EDIT MODE + +.note-editor .css_editable_mode_display { + border: 1px dashed rgba(153, 153, 153, 0.5); + display: block !important; +} + +.note-editable { + + li, + p, + h1, + h2, + h3, + h4, + h5, + h6 { + &:empty:after { + content: "\00a0" + } + } +} + +.wysiwyg_iframe { + margin: 0; + padding: 0; + border: 0; +} diff --git a/addons/web_editor/static/src/scss/wysiwyg_iframe.scss b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss new file mode 100644 index 00000000000..d019fdce753 --- /dev/null +++ b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss @@ -0,0 +1,24 @@ +iframe.wysiwyg_iframe.o_fullscreen { + position: fixed !important; + left: 0 !important; + right: 0 !important; + top: 0 !important; + bottom: 0 !important; + width: 100% !important; + min-height: 100% !important; + z-index: 1001 !important; + border: 0; +} + +body.o_in_iframe { + background-color: $o-view-background-color; + overflow: hidden; + + .note-editable { + height: calc(100vh - #{$o-wysiwyg-toolbar-height} - 2px); + } + + .note-statusbar { + display: none; + } +} diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss new file mode 100644 index 00000000000..b693fd38544 --- /dev/null +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -0,0 +1,872 @@ +/// +/// This file contains all variables and mixins that are specific to the editor. +/// + +$o-wysiwyg-handles-accent-color: #00e2ff !default; +$o-wysiwyg-handle-edge-size: 1px !default; +$o-wysiwyg-dropzone-size: 40px !default; +$o-wysiwyg-sidebar-width: 210px !default; +$o-wysiwyg-sidebar-height: 36px !default; +$o-wysiwyg-overlay-option-size: 25px !default; +$o-wysiwyg-handles-btn-size: 18px !default; +$o-wysiwyg-handle-border-width: 1px !default; + +$o-wysiwyg-handles-offset-to-hide: 10000px !default; + + +// NOTE EDITOR +.note-editor.o_snippets_loaded { + transition: padding-left 400ms $o-wysiwyg-md-ease 0s; + padding-left: 210px !important; +} + +.note-editor.note-frame .note-editing-area .o_wrap_editable_snippets { + overflow: auto; + + .note-editable { + overflow: visible; + } +} + +.o_in_iframe .note-editor.o_snippets_loaded .note-editable { + padding-top: 30px; +} + +// SNIPPET PANEL +#oe_snippets { + @include o-w-preserve-btn; + + display: flex; + flex-flow: column nowrap; + @include o-position-absolute(0, auto, 0, -$o-wysiwyg-sidebar-width); + position: absolute; + width: $o-wysiwyg-sidebar-width; + z-index: $o-wysiwyg-zindex - 1; + + font-family: Roboto, $font-family-sans-serif; + border-right: 1px solid #aaa; + transition: left 400ms $o-wysiwyg-md-ease 0s; + background-image: linear-gradient(45deg, $o-wysiwyg-bg-color, darken($o-wysiwyg-bg-color, 10%)); + box-shadow: 0px 10px 10px -10px black inset; + border: 1px solid $o-wysiwyg-border-color; + + #snippets_menu { + flex: 0 0 auto; + height: $o-wysiwyg-sidebar-height; + line-height: $o-wysiwyg-sidebar-height; + margin: 0; + padding: 0; + background: $o-wysiwyg-bg-color; + text-align: center; + font-weight: normal; + font-size: 17px; + color: $o-wysiwyg-color; + font-family: $o-wysiwyg-font-family; + } + + #o_scroll { + height: 100%; + overflow: auto; + + .o_panel_header { + display: flex; + align-items: center; + justify-content: center; + padding: 3%; + color: $o-wysiwyg-color; + margin-bottom: 3%; + + i { + margin-right: 5px; + } + } + + .o_panel_body { + &:after { + content: " "; + display: table; + clear: both; + } + + .oe_snippet { + float: left; + width: 45.5%; + margin: 0 0 6% 3%; + box-shadow: none; + background-color: transparent; + user-select: none; + + cursor: pointer; + cursor: copy; + cursor: grab; + + .oe_snippet_thumbnail { + .oe_snippet_thumbnail_img { + border: none; + } + + .oe_snippet_thumbnail_title { + border: none; + padding: 5px 0; + } + + &:hover .oe_snippet_thumbnail_img { + transition: transform 200ms ease 0s; + transform: scale(1.05); + } + } + + &.o_disabled { + .oe_snippet_thumbnail { + background-color: rgba(255, 0, 0, 0.75); + } + + .oe_snippet_thumbnail_img { + opacity: 0.8; + } + } + + &.o_snippet_install { + .oe_snippet_thumbnail_img { + opacity: 0.5; + } + + .btn.o_install_btn { + display: none; + @include o-position-absolute(16px, 4px, auto, 4px); + } + + &:hover .btn.o_install_btn { + display: block; + } + } + + &:nth-child(2n+1) { + clear: left; + } + } + } + } + + &.o_loaded { + left: 0 !important; + } +} + +.oe_snippet { + // No root because can be drag and drop (and the helper is in the body) + position: relative; + width: 100px; + background-color: $o-wysiwyg-bg-color; + z-index: $o-wysiwyg-zindex; + + > :not(.oe_snippet_thumbnail) { + display: none !important; + } + + .oe_snippet_thumbnail { + width: 100%; + + .oe_snippet_thumbnail_img { + width: 100%; + height: 0; + border: 1px solid $o-wysiwyg-border-color; + padding-bottom: 75%; + background-size: cover; + background-position: center center; + text-align: center; + overflow: hidden; + } + + img.oe_snippet_thumbnail_img { + height: auto; + padding-bottom: 0; + } + + .oe_snippet_thumbnail_title { + display: block; + border: 1px solid $o-wysiwyg-bg-color; + padding: 5px; + font-size: 12px; + font-weight: 300; + text-shadow: none; + color: $o-wysiwyg-color; + } + + &:hover .oe_snippet_thumbnail_title { + color: $o-wysiwyg-color-light; + } + } +} + +// SNIPPET OPTIONS +.colorpicker { + .o_colorpicker_sections { + font-size: 14px; + + .o_colorpicker_section_tabs { + width: 225px; + height: 100%; + } + + button { + float: left; + height: 25px; + width: 25px; + margin: 1px 1px 0 2px; + border: 1px solid black; + } + + div[data-group="transparent_grayscale"] button { + @include o-alpha-button-preview; + + &.o_small { + width: 23px; + height: 23px; + margin: 2px; + border-radius: 50%; + } + + &:hover, + &.selected { + box-shadow: 0px 0px 2px 2px $o-wysiwyg-color; + } + + &.selected:before { + content: "\f00c"; + font-family: "FontAwesome"; + padding-top: 1px; + color: theme-color('success'); + } + + &[data-event="foreColor"] { + background-color: $o-wysiwyg-bg-color; + + &:before { + background-color: rgba(white, 0.3); + } + } + + &.o_btn_transparent::before { + background-color: transparent; + } + } + + .note-color-palette>div { + clear: both; + } + + .note-color-row { + height: 28px; + } + } + + .note-palette-title { + padding: 10px 0px 10px 10px; + color: $o-wysiwyg-color-light; + font-weight: bold; + } + + .palette-reset { + @include o-position-absolute(0, 0); + margin: 0; + padding: 5px 10px 0; + + .note-color-reset { + font-size: 20px !important; + color: desaturate(rgba($o-wysiwyg-color-danger, 0.6), 40%); + margin: 1px 0 0; + padding: 0; + cursor: pointer; + + &:hover { + background: transparent !important; + color: $o-wysiwyg-color-danger; + } + } + } +} + +// DROPZONES +@keyframes dropZoneInsert { + to { + background-color: rgba($o-brand-odoo, 0.2); + } +} + +.note-editable .oe_drop_zone { + background-color: rgba($o-brand-odoo, 0.05); + animation: dropZoneInsert 1s linear 0s infinite alternate; + + &.oe_insert { + z-index: $o-wysiwyg-zindex - 1; + + border: 2px dashed $o-wysiwyg-border-color; + border-top: none; + border-bottom: none; + + position: relative; + width: 100%; + height: $o-wysiwyg-dropzone-size; + margin: (-$o-wysiwyg-dropzone-size/2) 0; + + &:after { + content: ""; + display: block; + width: 100%; + height: 50%; + border-bottom: 2px dashed $o-wysiwyg-border-color; + box-sizing: content-box; + } + + &.oe_vertical { + border: 2px dashed $o-brand-odoo; + border-left: none; + border-right: none; + + width: $o-wysiwyg-dropzone-size; + float: left; + margin: 0 (-$o-wysiwyg-dropzone-size/2); + + &:after { + width: 50%; + height: 100%; + border-bottom: none; + border-right: 2px dashed $o-wysiwyg-border-color; + } + } + } +} + +// MANIPULATORS +#oe_manipulators { + position: relative; + + // SNIPPET MANIPULATORS + .oe_overlay { + @include o-position-absolute; + display: none; + height: 0; + background: transparent; + text-align: center; + transition: opacity 400ms linear 0s; + + &.o_keypress { + opacity: 0; + } + + &.oe_active { + display: block; + } + + // OVERLAY OPTIONS + >.oe_overlay_options { + font-family: $o-wysiwyg-font-family; + @include o-position-absolute($bottom: 0, $left: 0); + width: 250px; // the parent oe_overlay has a width equal to the snippet width, the 250px is there to force the options to stay on one line without overriding bootstrap .btn-group + text-align: left; + z-index: $o-wysiwyg-zindex + 1; + + >.btn-group { + white-space: nowrap; + border: 0; + + .btn { + display: inline-block; + width: $o-wysiwyg-overlay-option-size; + height: $o-wysiwyg-overlay-option-size; + padding: 0; + border: 1px solid $o-wysiwyg-bg-color; + line-height: $o-wysiwyg-overlay-option-size; + font-size: 11px; + border-radius: 0; + font-weight: normal; + + transition: all 400ms ease 0s; + color: $o-wysiwyg-color; + background-color: $o-wysiwyg-bg-color; + + &:hover { + color: $o-wysiwyg-color-light; + background-color: lighten($o-wysiwyg-bg-color, 10%); + } + + &.oe_snippet_remove { + color: $o-wysiwyg-color; + background-color: $o-wysiwyg-color-danger; + border-color: darken($o-wysiwyg-color-danger, 20%); + + >.fa { + color: $o-wysiwyg-color; + } + + &:hover { + background-color: darken($o-wysiwyg-color-danger, 20%); + } + } + + &.oe_snippet_parent>i { + transform: scaleX(-1); + font-size: 12px; + } + + >.fa { + color: $o-wysiwyg-color; + line-height: inherit; + vertical-align: top; + } + } + + // CUSTOMIZE MENU BUTTON + >.oe_options { + .btn { + width: auto; + padding: 0 25px 0 5px; + background-color: $o-wysiwyg-bg-color; + text-transform: uppercase; + + &:hover, + &:active, + &:focus { + background-color: lighten($o-wysiwyg-bg-color, 5%); + border-color: lighten($o-wysiwyg-bg-color, 10%); + } + + &:before, + &:after { + content: ""; + width: 7px; + height: 2px; + background-color: $o-wysiwyg-color-light; + @include o-position-absolute(46%); + transition: all 0.3s ease 0s; + } + + &:before { + right: 9px; + transform: rotate(45deg); + } + + &:after { + right: 5px; + transform: rotate(-45deg); + } + } + + // Open menu + &.show .btn { + + &:active, + &:focus, + &:active:focus { + box-shadow: none; + } + + &:before, + &:after { + width: 9px; + } + + &:before { + right: 10px; + transform: translateX(5px) rotate(-45deg); + } + + &:after { + transform: rotate(45deg); + } + } + + // CUSTOMIZE MENU + .dropdown-menu { + margin: -1px 0 0 0; + padding: 0; + border: 1px solid $o-wysiwyg-bg-color; + border-radius: 0; + background-color: $o-wysiwyg-bg-color; + + .dropdown-item { + position: relative; + color: $o-wysiwyg-color-light; + font-weight: normal; + font-size: 12px; + padding: 5px 40px 5px 10px; + + &:hover { + background: transparent; + cursor: pointer; + } + + &.active { + &:before { + @include o-position-absolute($top: 5px, $right: 5px); + content: "\f00c"; + font-family: "FontAwesome"; + color: $o-brand-primary; + } + } + + >i { + width: 15px; + margin-right: 10px; + text-align: center; + } + } + + .dropdown-submenu { + position: relative; + + &::before { + @include o-position-absolute($top: 12px, $right: 10px); + @include o-caret-right(4px); + border-left-color: $o-wysiwyg-color-light; + } + + &:hover>.dropdown-menu { + display: block; + left: 100%; + top: 0; + + &.o_open_to_left { + left: auto; + right: 100%; + } + } + } + + .dropdown-divider { + display: block; + height: 1px; + border-top: 1px solid #666; + margin: 0; + } + + .dropdown-header { + color: $o-wysiwyg-color-light; + font-size: 11px; + margin-top: 2px; + text-transform: uppercase; + + &:hover { + background-color: initial; + } + + &.o_main_header { + padding-left: 10px; + } + + &.o_parent_editor_header { + position: relative; + color: $o-wysiwyg-color-light; + font-weight: 400; + font-size: 10px; + margin: 10px 0 0 10px; + text-transform: uppercase; + padding-bottom: .25em; + + &::before { + content: "\f148"; + @include o-position-absolute($top: 5px, $left: 4px); + font-family: FontAwesome; + transform: scaleX(-1); + } + + ~.dropdown-submenu .dropdown-item, + ~.dropdown-item { + font-size: 11px; + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + + ~.dropdown-submenu::before { + @include o-position-absolute($top: 10px, $right: 10px); + } + } + } + } + } + } + } + + &.o_top_cover>.oe_overlay_options { + bottom: auto; + top: 1px; + right: 1px; + } + + // HANDLES + >.o_handles { + @include o-position-absolute(-$o-wysiwyg-handles-offset-to-hide, 0, auto, 0); + z-index: $o-wysiwyg-zindex; + + &:hover>.o_handle { + background-color: rgba($o-wysiwyg-handles-accent-color, 0.05); + } + + >.o_handle { + + position: relative; + border: 0 solid $o-wysiwyg-handles-accent-color; + transition: background 300ms ease 0s; + + &:hover, + &.o_active { + background-color: rgba($o-wysiwyg-handles-accent-color, 0.2); + + &:before { + content: ''; + @include o-position-absolute(0, $left: 50%); + width: 1px; + height: 100%; + margin-left: -1px; + background-color: rgba($o-wysiwyg-handles-accent-color, 0.5); + } + + &.w:before { + @include o-position-absolute(50%, $left: 0); + width: 100%; + height: 1px; + } + + &:after { + border-color: darken($o-wysiwyg-handles-accent-color, 10%); + background-color: darken($o-wysiwyg-handles-accent-color, 20%); + } + } + + &:after { + display: block; + width: $o-wysiwyg-handles-btn-size; + height: $o-wysiwyg-handles-btn-size; + border: solid 1px darken($o-wysiwyg-handles-accent-color, 10%); + line-height: $o-wysiwyg-handles-btn-size - 2; + font-size: 14px; + font-family: FontAwesome; + background-color: darken($o-wysiwyg-handles-accent-color, 20%); + color: white; + } + + &.w:after, + &.e:after { + content: "\f07e"; + } + + &.s:after, + &.n:after { + content: "\f07d"; + } + + &.o_handle_start { + + &.w:after, + &.e:after { + content: '\f061'; + } + + &.n:after, + &.s:after { + content: '\f063'; + } + } + + &.o_handle_end { + + &.w:after, + &.e:after { + content: '\f060'; + } + + &.n:after, + &.s:after { + content: '\f062'; + } + } + + &.w { + @include o-position-absolute($o-wysiwyg-handles-offset-to-hide, auto, -$o-wysiwyg-handles-offset-to-hide, 0); + width: $o-wysiwyg-handle-edge-size; + border-width: $o-wysiwyg-handle-border-width; + border-right-width: 0; + cursor: e-resize; + + &:after { + @include o-position-absolute($top: 50%, $left: 40%); + margin-top: -$o-wysiwyg-handles-btn-size/2; + } + } + + &.e { + @include o-position-absolute($o-wysiwyg-handles-offset-to-hide, 0, -$o-wysiwyg-handles-offset-to-hide, auto); + width: $o-wysiwyg-handle-edge-size; + border-right-width: $o-wysiwyg-handle-border-width; + cursor: w-resize; + + &:after { + @include o-position-absolute($top: 50%, $right: 40%); + margin-top: -$o-wysiwyg-handles-btn-size/2; + } + } + + &.n { + @include o-position-absolute($o-wysiwyg-handles-offset-to-hide, 0, auto, 0); + border-top-width: $o-wysiwyg-handle-border-width; + cursor: ns-resize; + + &:after { + @include o-position-absolute($left: 50%, $top: 40%); + margin-left: -$o-wysiwyg-handles-btn-size/2; + } + } + + &.s { + @include o-position-absolute(auto, 0, -$o-wysiwyg-handles-offset-to-hide, 0); + border-bottom-width: $o-wysiwyg-handle-border-width; + cursor: ns-resize; + + &:after { + @include o-position-absolute($left: 50%, $bottom: 40%); + margin-left: -$o-wysiwyg-handles-btn-size/2; + } + } + + &.readonly { + cursor: auto !important; + + &:after { + display: none !important; + } + + &:hover { + opacity: 0.5; + } + } + } + } + } + + .contact_menu { + display: block; + top: -24px; + margin: 0px; + padding: 2px 0px; + position: relative; + min-height: 44px; + position: absolute !important; + } +} + +.s-resize-important * { + cursor: s-resize !important; +} + +.n-resize-important * { + cursor: n-resize !important; +} + +.e-resize-important * { + cursor: e-resize !important; +} + +.w-resize-important * { + cursor: w-resize !important; +} + +.move-important * { + cursor: move !important; +} + +.dropdown-menu label .o_switch { + margin: 0; + padding: 2px 0; +} + +.text-input-group { + position: relative; + margin-bottom: 45px; + + input { + font-size: 18px; + padding: 10px 10px 10px 5px; + display: block; + width: 300px; + border: none; + border-bottom: 1px solid #757575; + } + + input:focus { + outline: none; + } + + /* LABEL ======================================= */ + label { + color: #999; + font-size: 18px; + font-weight: normal; + @include o-position-absolute($top: 10px, $left: 5px); + pointer-events: none; + transition: 0.2s ease all; + } + + /* active state */ + input:focus~label, + input:valid~label { + top: -20px; + font-size: 14px; + color: #5264AE; + } + + /* BOTTOM BARS ================================= */ + .bar { + position: relative; + display: block; + width: 300px; + } + + .bar:before, + .bar:after { + content: ''; + height: 2px; + width: 0; + bottom: 1px; + @include o-position-absolute; + background: #5264AE; + transition: 0.2s ease all; + } + + .bar:before { + left: 50%; + } + + .bar:after { + right: 50%; + } + + /* active state */ + input:focus~.bar:before, + input:focus~.bar:after { + width: 50%; + } + + /* HIGHLIGHTER ================================== */ + .highlight { + @include o-position-absolute($top: 25%, $left: 0); + height: 60%; + width: 100px; + pointer-events: none; + opacity: 0.5; + } + + /* active state */ + input:focus~.highlight { + animation: inputHighlighter 0.3s ease; + } +} + +// DRAG&DROP ANIMATIONS +.oe_snippet_body { + opacity: 0; + animation: fadeInDownSmall 700ms forwards; +} diff --git a/addons/web_editor/static/src/scss/wysiwyg_variables.scss b/addons/web_editor/static/src/scss/wysiwyg_variables.scss new file mode 100644 index 00000000000..2beafe71681 --- /dev/null +++ b/addons/web_editor/static/src/scss/wysiwyg_variables.scss @@ -0,0 +1,42 @@ +// Mixins for buttons with alpha bg-color +@mixin o-alpha-button-preview { + position: relative; + z-index: 1; + + // Place an overlay that inherit the button's bg-color. + &::before { + content: ""; + @include o-position-absolute(0, 0, 0, 0); + background-color: inherit; + } + + &::after { + content: ""; + @include o-position-absolute(0, 0, 0, 0); + z-index: -1; + background-image: url('/web_editor/static/src/img/transparent.png'); + background-size: 10px auto; + } +} + +$o-wysiwyg-zindex: 1000; + +$o-wysiwyg-md-ease: cubic-bezier(0.19, 1, 0.22, 1) !default; + +$o-wysiwyg-font-family: Roboto, +"Montserrat", +"Segoe UI", +"Helvetica Neue", +Helvetica, +Arial, +sans-serif; + +$o-wysiwyg-toolbar-height: 32px; + +$o-wysiwyg-color: #212629 !default; +$o-wysiwyg-color-light: #616669 !default; +$o-wysiwyg-color-danger: #912629 !default; +$o-wysiwyg-color-success: #219629 !default; +$o-wysiwyg-bg-color: #f7f7f7 !default; +$o-wysiwyg-bg-color-dark: #a7a7a7 !default; +$o-wysiwyg-border-color: #a9a9a9 !default; diff --git a/addons/web_editor/static/src/xml/backend.xml b/addons/web_editor/static/src/xml/backend.xml index 146d96fe1e5..904f21f440c 100644 --- a/addons/web_editor/static/src/xml/backend.xml +++ b/addons/web_editor/static/src/xml/backend.xml @@ -1,12 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <templates id="template" xml:space="preserve"> - <t t-name="web_editor.FieldTextHtml"> - <div class="oe_form_field oe_form_field_html oe_form_embedded_html" t-att-style="widget.attrs.style"> - <iframe/> - </div> - </t> - <t t-name="web_editor.FieldTextHtml.button.translate"> <div class="btn-group float-right"> <button t-if="widget.field.translate" class="o_field_translate btn btn-secondary" style="height: 24px; padding: 1px 17px 0px 5px" aria-label="Translate" title="Translate"> @@ -15,12 +9,4 @@ </div> </t> - <t t-name="web_editor.FieldTextHtml.fullscreen"> - <span style="margin: 5px; position: fixed; top: 0; right: 0; z-index: 2000;"> - <button class="o_fullscreen btn btn-primary" style="width: 24px; height: 24px; background-color: #337ab7; border: 1px solid #2e6da4; border-radius: 4px; padding: 0; position: relative;"> - <img src="/web_editor/font_to_img/61541/rgb(255,255,255)/16" style="position: absolute; top: 3px; left: 4px;" alt="Fullscreen"/> - </button> - </span> - </t> - </templates> diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml index 05e7d584337..51c3d5412ff 100644 --- a/addons/web_editor/static/src/xml/editor.xml +++ b/addons/web_editor/static/src/xml/editor.xml @@ -1,378 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <templates id="template" xml:space="preserve"> <!--=================--> - <!-- Base components --> - <!--=================--> - - <!-- Editor top bar which contains the summernote tools and save/discard buttons --> - <t t-name="web_editor.editorbar"> - <div id="web_editor-top-edit"> - <div id="web_editor-toolbars"/> - <form class="navbar-form text-muted"> - <button type="button" class="btn btn-secondary" data-action="cancel"><i class="fa fa-times"/> Discard</button> - <button type="button" class="btn btn-primary" data-action="save"><i class="fa fa-floppy-o"/> Save</button> - </form> - </div> - </t> - - <!-- Custom checkbox (material-design-like toggle) --> - <t t-name="web_editor.components.switch"> - <label class="o_switch" t-att-for="id"> - <input type="checkbox" t-att-id="id"/> - <span/> - <t t-if="label"><t t-esc="label"/></t> - </label> - </t> - - <!--=================--> - <!-- Edition Dialogs --> - <!--=================--> - - <!-- Alt Dialog (allows to change alt and title of page images) --> - <form t-name="web_editor.dialog.alt" action="#"> - <div class="form-group row"> - <label class="col-md-3 col-form-label" for="alt" - title="'Alt tag' specifies an alternate text for an image, if the image cannot be displayed (slow connection, missing image, screen reader ...)."> - Description <small>(ALT Tag)</small> - </label> - <div class="col-md-8"> - <input class="form-control" id="alt" required="required" t-att-value="widget.alt" type="text"/> - </div> - </div> - <div class="form-group row"> - <label class="col-md-3 col-form-label" for="title" - title="'Title tag' is shown as a tooltip when you hover the picture."> - Tooltip <small>(TITLE Tag)</small> - </label> - <div class="col-md-8"> - <input class="form-control" id="title" required="required" t-att-value="widget.tag_title" type="text"/> - </div> - </div> - </form> - - <!-- Media Dialog (allows to choose an img/pictogram/video) --> - <div t-name="web_editor.dialog.media"> - <ul class="nav nav-tabs" role="tablist"> - <li class="nav-item"><a role="tab" href="#editor-media-image" aria-controls="editor-media-image" data-toggle="tab" t-attf-class="nav-link#{widget.noImages ? ' d-none' : ''}">Image</a></li> - <li class="nav-item"><a role="tab" href="#editor-media-document" aria-controls="editor-media-document" data-toggle="tab" t-attf-class="nav-link#{widget.noDocuments ? ' d-none' : ''}">Document</a></li> - <li class="nav-item"><a role="tab" href="#editor-media-icon" aria-controls="editor-media-icon" data-toggle="tab" t-attf-class="nav-link#{widget.noIcons ? ' d-none' : ''}">Pictogram</a></li> - <li class="nav-item"><a role="tab" href="#editor-media-video" aria-controls="editor-media-video" data-toggle="tab" t-attf-class="nav-link#{widget.noVideos ? ' d-none' : ''}">Video</a></li> - </ul> - <!-- Tab panes --> - <div class="tab-content"> - <div role="tabpanel" class="tab-pane fade" id="editor-media-image"/> - <div role="tabpanel" class="tab-pane fade" id="editor-media-document"/> - <div role="tabpanel" class="tab-pane fade" id="editor-media-icon"/> - <div role="tabpanel" class="tab-pane fade" id="editor-media-video"/> - </div> - </div> - <t t-name="web_editor.dialog.media.search"> - <div class="input-group ml-auto"> - <input type="text" class="form-control o_we_search" t-att-placeholder="searchPlaceholder.trim()"/> - <div class="input-group-append"> - <div class="input-group-text o_we_search_icon"> - <i class="fa fa-search" title="Search" role="img" aria-label="Search"/> - </div> - </div> - </div> - </t> - - <!-- Image choosing part of the Media Dialog --> - <t t-name="web_editor.dialog.image"> - <t t-set="iframeName" t-value="_.uniqueId('fileframe-')"/> - <form method="POST" - action="/web_editor/attachment/add" - enctype="multipart/form-data" - t-att-target="iframeName"> - <input type="hidden" name="csrf_token" t-att-value="csrf_token"/> - <input type="hidden" name="filters" t-att-value="widget.firstFilters.join('_')"/> - <input t-if="widget.options.res_id" type="hidden" name="res_id" t-att-value="widget.options.res_id"/> - <input t-if="widget.options.res_model" type="hidden" name="res_model" t-att-value="widget.options.res_model"/> - <t t-call="web_editor.dialog.files.submenu"> - <t t-set="isDocument" t-value="widget.options.document"/> - <t t-set="accept" t-value="widget.accept"/> - </t> - <div class="form-text"/> - <div class="existing-attachments"/> - <div class="mt-4 text-center mx-auto"> - <button class="btn btn-primary o_load_more" type="button">Load more...</button> - <div class="mt-4 o_load_done_msg d-none"> - <span><i>All attachments have been loaded</i></span> - </div> - </div> - <iframe class="o_file_upload_iframe d-none" t-att-name="iframeName"/> - </form> - </t> - <t t-name="web_editor.dialog.files.submenu"> - <div class="form-inline align-items-center py-4"> - <input type="file" class="d-none o_file_input" name="upload" t-att-accept="accept" multiple="multiple"/> - <input type="hidden" name="disable_optimization" value=""/> - - <div class="btn-group"> - <button type="button" class="btn btn-primary o_upload_media_button"> - <t t-if="isDocument">Upload a document</t> - <t t-else="">Upload an image</t> - </button> - <t t-if="!isDocument"> - <button class="btn btn-primary dropdown-toggle dropdown-toggle-split o_media_optimization" data-toggle="dropdown" type="button"> - <span class="sr-only">Alternate Upload</span> - </button> - <div class="dropdown-menu" role="menu"> - <button type="button" role="menuitem" class="dropdown-item o_upload_media_button_no_optimization">Upload image without optimization</button> - </div> - </t> - </div> - - <div class="input-group align-items-center ml-2"> - <input type="text" class="form-control o_we_url_input" name="url" t-attf-placeholder="https://www.odoo.com/#{isDocument ? 'mydocument' : 'logo.png'}"/> - <div class="input-group-append align-items-center"> - <button type="button" class="btn btn-secondary o_upload_media_url_button" disabled="disabled"> - <t t-if="isDocument">Add document</t> - <t t-else="">Add image</t> - </button> - <div class="ml-2"> - <span class="o_we_url_success text-success d-none fa fa-lg fa-check" title="The URL seems valid"/> - <span class="o_we_url_warning text-warning d-none fa fa-lg fa-warning" title="The URL does not contain any image. The file will be added in the document section."/> - <span class="o_we_url_error text-danger d-none fa fa-lg fa-times" title="The URL does not seem to work"/> - </div> - </div> - </div> - - <t t-call="web_editor.dialog.media.search"> - <t t-set="searchPlaceholder"> - <t t-if="isDocument">Search a document</t> - <t t-else="">Search an image</t> - </t> - </t> - </div> - </t> - <t t-name="web_editor.dialog.files.existing.content"> - <div class="existing-attachments"> - <div t-foreach="rows" t-as="row" class="row mt-2"> - <div t-foreach="row" t-as="attachment" class="col-2 o_existing_attachment_cell"> - <i t-if="attachment.res_model === 'ir.ui.view'" class="fa fa-trash o_existing_attachment_remove" title="This file is a public view attachment" role="img" aria-label="This file is a public view attachment" t-att-data-id="attachment.id"/> - <i t-else="" class="fa fa-trash o_existing_attachment_remove" title="This file is attached to the current record" role="img" aria-label="This file is attached to the current record" t-att-data-id="attachment.id"/> - <div class="o_attachment_border" t-att-style="attachment.res_model === 'ir.ui.view' ? null : 'border: 1px solid #5cb85c;'"><div t-att-data-src="attachment.src" t-att-data-url="attachment.url" role="img" t-att-aria-label="attachment.name" t-att-title="attachment.name" t-att-data-id="attachment.id" t-att-data-mimetype="attachment.mimetype" t-attf-class="o_image #{withEffect ? 'o_image_loading' : ''}"/></div> - <t t-if="isDocument"> - <small class="o_file_name"><t t-esc="attachment.name"/></small> - </t> - </div> - </div> - </div> - </t> - <t t-name="web_editor.dialog.image.existing.error"> - <div class="form-text"> - <p>The image could not be deleted because it is used in the - following pages or views:</p> - <ul t-as="view" t-foreach="views"> - <li> - <a t-attf-href="/web#model=ir.ui.view&id=#{view.id}"> - <t t-esc="view.name"/> - </a> - </li> - </ul> - </div> - </t> - - <!-- Icon choosing part of the Media Dialog --> - <t t-name="web_editor.dialog.font-icons"> - <form action="#"> - <div class="form-inline align-items-center py-4"> - <t t-call="web_editor.dialog.media.search"> - <t t-set="searchPlaceholder">Search a pictogram</t> - </t> - </div> - <div class="font-icons-icons"> - <t t-call="web_editor.dialog.font-icons.icons"> - <t t-set="iconsParser" t-value="widget.iconsParser"/> - </t> - </div> - </form> - </t> - <t t-name="web_editor.dialog.font-icons.icons"> - <t t-as="data" t-foreach="iconsParser"> - <span t-foreach="data.cssData" t-as="cssData" - t-att-data-id="cssData.names[0]" - t-att-title="cssData.names[0]" - t-att-aria-label="cssData.names[0]" role="img" - t-attf-class="font-icons-icon #{data.base} #{cssData.names[0]}" - t-att-data-alias="cssData.names.join(',')"/> - </t> - </t> - - <!-- Video choosing part of the Media Dialog --> - <t t-name="web_editor.dialog.video"> - <form action="#" class="row"> - <div class="col mt-4 o_video_dialog_form"> - <div class="form-group mb-2" id="o_video_form_group"> - <label class="col-form-label" for="o_video_text"> - Video code <small>(URL or Embed)</small> - <div class="o_validate_feedback"> - <i class="fa fa-check text-success" role="img" aria-label="Checked" title="Checked"/> - <i class="fa fa-exclamation-triangle text-danger" role="img" aria-label="Attention" title="Attention"/> - </div> - </label> - <textarea class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here"/> - </div> - <div class="text-right"> - <small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Instagram</i></b>, <b><i>Vine.co</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small> - </div> - <div class="o_video_dialog_options d-none mt-4"> - <div class="o_yt_option o_vim_option o_dm_option"> - <label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label> - </div> - <div class="o_yt_option o_vim_option"> - <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label> - </div> - <div class="o_yt_option o_dm_option"> - <label class="o_switch mb0"><input id="o_video_hide_controls" type="checkbox"/><span/>Hide player controls</label> - </div> - <div class="o_yt_option"> - <label class="o_switch mb0"><input id="o_video_hide_fullscreen" type="checkbox"/><span/>Hide fullscreen button</label> - </div> - <div class="o_yt_option"> - <label class="o_switch mb0"><input id="o_video_hide_yt_logo" type="checkbox"/><span/>Hide Youtube logo</label> - </div> - <div class="o_dm_option"> - <label class="o_switch mb0"><input id="o_video_hide_dm_logo" type="checkbox"/><span/>Hide Dailymotion logo</label> - </div> - <div class="o_dm_option"> - <label class="o_switch mb0"><input id="o_video_hide_dm_share" type="checkbox"/><span/>Hide sharing button</label> - </div> - </div> - </div> - <div class="col-md-6"> - <div id="video-preview" class="p-3"> - <div class="o_video_dialog_preview_text small mb-2 d-none">Preview</div> - <div class="media_iframe_video"> - <div class="media_iframe_video_size"/> - <iframe class="o_video_dialog_iframe" allowfullscreen="allowfullscreen" frameborder="0" src=""/> - </div> - </div> - </div> - </form> - </t> - - <!-- Link Dialog (allows to choose a style and content for a link on the page) --> - <div t-name="web_editor.dialog.link" class="o_link_dialog"> - <div class="row"> - <form class="col-lg-8"> - <div t-attf-class="form-group row#{widget.needLabel ? '' : ' d-none'}"> - <label class="col-form-label col-md-3" for="o_link_dialog_label_input">Link Label</label> - <div class="col-md-9"> - <input type="text" name="label" class="form-control" id="o_link_dialog_label_input" required="required" t-att-value="widget.data.text"/> - </div> - </div> - <div class="form-group row o_url_input"> - <label class="col-form-label col-md-3" for="o_link_dialog_url_input">URL or Email</label> - <div class="col-md-9"> - <input type="text" name="url" class="form-control" id="o_link_dialog_url_input" required="required"/> - </div> - </div> - <div class="form-group row"> - <label class="col-form-label col-md-3">Size</label> - <div class="col-md-9"> - <select name="link_style_size" class="form-control link-style"> - <option value="sm">Small</option> - <option value="" selected="selected">Medium</option> - <option value="lg">Large</option> - </select> - </div> - </div> - <div class="form-group row"> - <label class="col-form-label col-md-3">Style</label> - <div class="col-md-9"> - <select name="link_style_shape" class="form-control link-style"> - <option value="" selected="selected">Default</option> - <option value="outline">Outline</option> - <option value="rounded-circle">Rounded</option> - <option value="outline,rounded-circle">Outline-Rounded</option> - <option value="flat">Flat</option> - </select> - </div> - </div> - <div class="form-group row"> - <label class="col-form-label col-md-3">Color</label> - <div class="col-md-9"> - <div class="o_link_dialog_color"> - <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-link text-center" data-color=""> - <span>L</span> - <input class="d-none link-style" name="link_style_color" type="radio" value=""/> - <i class="fa"/> - </label> - <t t-foreach="['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'primary', 'secondary', 'success', 'info', 'warning', 'danger']" t-as="color"> - <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-#{color}" t-attf-aria-label="Color for #{color}" t-attf-title="Color for #{color}"> - <input type="radio" name="link_style_color" class="d-none link-style" t-att-value="color"/> - <i class="fa"/> - </label> - </t> - </div> - </div> - </div> - <div class="form-group row"> - <div class="offset-md-3 col-md-9"> - <label class="o_switch"> - <input type="checkbox" name="is_new_window" t-att-checked="widget.data.isNewWindow ? 'checked' : undefined"/> - <span/> - Open in new window - </label> - </div> - </div> - </form> - <div class="col-lg-4 o_link_dialog_preview"> - <div class="form-group text-center"> - <label>Preview</label> - <div style="overflow: auto; max-width: 100%; max-height: 200px;"> - <a href="#" id="link-preview" aria-label="Preview" title="Preview"/> - </div> - </div> - </div> - </div> - </div> - - <!-- Crop Image Dialog (allows to crop image on the page) --> - <div t-name="web_editor.dialog.crop_image" class="o_crop_image_dialog"> - <div t-if="widget.imageData.isExternalImage" class="alert alert-warning text-center" role="alert"> - <i class="fa fa-exclamation-triangle fa-2x"/> - <h4>This image is an external image</h4> - <p> - This type of image is not supported for cropping.<br/> - If you want to crop it, please first download it from the original source and upload it in Odoo. - </p> - </div> - <t t-else=""> - <div class="o_crop_area"> - <img class="img img-fluid o_cropper_image" t-att-src="widget.imageData.originalSrc"/> - </div> - <div class="o_crop_options text-center mt16"> - <div class="btn-group btn-group-toggle" title="Aspect Ratio" data-toggle="buttons"> - <t t-foreach="widget.aspectRatioList" t-as="ratio"> - <t t-set="is_active" t-value="ratio[1] === widget.imageData.aspectRatio"/> - <label t-attf-class="btn btn-secondary#{is_active and ' active' or ''}" data-event="ratio" t-att-data-label="ratio[1]" t-att-data-value="ratio[2]"> - <input type="radio" /><t t-esc="ratio[0]"/> - </label> - </t> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Zoom In" data-event="zoom" data-value="0.1"><i class="fa fa-search-plus"/></button> - <button type="button" class="btn btn-secondary" title="Zoom Out" data-event="zoom" data-value="-0.1"><i class="fa fa-search-minus"/></button> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Rotate Left" data-event="rotate" data-value="-45"><i class="fa fa-rotate-left"/></button> - <button type="button" class="btn btn-secondary" title="Rotate Right" data-event="rotate" data-value="45"><i class="fa fa-rotate-right"/></button> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Flip Horizontal" data-event="flip" data-value="horizontal" data-x="1"><i class="fa fa-arrows-h"/></button> - <button type="button" class="btn btn-secondary" title="Flip Vertical" data-event="flip" data-value="vertical" data-y="1"><i class="fa fa-arrows-v"/></button> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Reset Image" data-event="reset"><i class="fa fa-refresh"/> Reset Image</button> - </div> - </div> - </t> - </div> - - <!--=================--> <!-- Snippet options --> <!--=================--> diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml index 7b85fb3998c..6e2ae656f2d 100644 --- a/addons/web_editor/static/src/xml/snippets.xml +++ b/addons/web_editor/static/src/xml/snippets.xml @@ -40,9 +40,9 @@ <t t-name="web_editor.many2one.button"> <div class="btn-group"> <a role="button" href="#" class="btn btn-secondary dropdown-toggle" data-toggle="dropdown" data-hover="dropdown" title="Search Contact" aria-label="Search Contact"><i class="fa fa-search"></i></a> - <div class="dropdown-menu contact_menu" role="menu"> - <a role="menuitem" class="dropdown-item"><input href="#" type="email" placeholder="Search"/></a> - </div> + <ul class="dropdown-menu contact_menu" role="menu"> + <li><a role="menuitem" class="dropdown-item"><input href="#" type="email" placeholder="Search"/></a></li> + </ul> </div> </t> diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml new file mode 100644 index 00000000000..2330629a93b --- /dev/null +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -0,0 +1,375 @@ +<?xml version="1.0" encoding="utf-8"?> +<templates id="template" xml:space="preserve"> + + <!--=================--> + <!-- Edition Dialogs --> + <!--=================--> + + <!-- Alt Dialog (allows to change alt and title of page images) --> + <form t-name="wysiwyg.widgets.alt" action="#"> + <div class="form-group row"> + <label class="col-md-3 col-form-label" for="alt" + title="'Alt tag' specifies an alternate text for an image, if the image cannot be displayed (slow connection, missing image, screen reader ...)."> + Description <small>(ALT Tag)</small> + </label> + <div class="col-md-8"> + <input class="form-control" id="alt" required="required" t-att-value="widget.alt" type="text"/> + </div> + </div> + <div class="form-group row"> + <label class="col-md-3 col-form-label" for="title" + title="'Title tag' is shown as a tooltip when you hover the picture."> + Tooltip <small>(TITLE Tag)</small> + </label> + <div class="col-md-8"> + <input class="form-control" id="title" required="required" t-att-value="widget.tag_title" type="text"/> + </div> + </div> + </form> + + <!-- Media Dialog (allows to choose an img/pictogram/video) --> + <div t-name="wysiwyg.widgets.media"> + <ul class="nav nav-tabs" role="tablist"> + <li class="nav-item"><a role="tab" href="#editor-media-image" aria-controls="editor-media-image" data-toggle="tab" t-attf-class="nav-link#{widget.noImages ? ' d-none' : ''}">Image</a></li> + <li class="nav-item"><a role="tab" href="#editor-media-document" aria-controls="editor-media-document" data-toggle="tab" t-attf-class="nav-link#{widget.noDocuments ? ' d-none' : ''}">Document</a></li> + <li class="nav-item"><a role="tab" href="#editor-media-icon" aria-controls="editor-media-icon" data-toggle="tab" t-attf-class="nav-link#{widget.noIcons ? ' d-none' : ''}">Pictogram</a></li> + <li class="nav-item"><a role="tab" href="#editor-media-video" aria-controls="editor-media-video" data-toggle="tab" t-attf-class="nav-link#{widget.noVideos ? ' d-none' : ''}">Video</a></li> + </ul> + <!-- Tab panes --> + <div class="tab-content"> + <div role="tabpanel" class="tab-pane fade" id="editor-media-image"/> + <div role="tabpanel" class="tab-pane fade" id="editor-media-document"/> + <div role="tabpanel" class="tab-pane fade" id="editor-media-icon"/> + <div role="tabpanel" class="tab-pane fade" id="editor-media-video"/> + </div> + </div> + + <t t-name="wysiwyg.widgets.media.search"> + <div class="input-group ml-auto"> + <input type="text" class="form-control o_we_search" t-att-placeholder="searchPlaceholder.trim()"/> + <div class="input-group-append"> + <div class="input-group-text o_we_search_icon"> + <i class="fa fa-search" title="Search" role="img" aria-label="Search"/> + </div> + </div> + </div> + </t> + + <!-- Image choosing part of the Media Dialog --> + <t t-name="wysiwyg.widgets.image"> + <t t-set="iframeName" t-value="_.uniqueId('fileframe-')"/> + <form method="POST" + action="/web_editor/attachment/add" + enctype="multipart/form-data" + t-att-target="iframeName"> + <input type="hidden" name="csrf_token" t-att-value="csrf_token"/> + <input type="hidden" name="filters" t-att-value="widget.firstFilters.join('_')"/> + <input t-if="widget.options.res_id" type="hidden" name="res_id" t-att-value="widget.options.res_id"/> + <input t-if="widget.options.res_model" type="hidden" name="res_model" t-att-value="widget.options.res_model"/> + <t t-call="wysiwyg.widgets.files.submenu"> + <t t-set="isDocument" t-value="widget.options.document"/> + <t t-set="accept" t-value="widget.accept"/> + </t> + <div class="form-text"/> + <div class="existing-attachments"/> + <div class="mt-4 text-center mx-auto"> + <button class="btn btn-primary o_load_more" type="button">Load more...</button> + <div class="mt-4 o_load_done_msg d-none"> + <span><i>All attachments have been loaded</i></span> + </div> + </div> + <iframe class="o_file_upload_iframe d-none" t-att-name="iframeName"/> + </form> + </t> + + <t t-name="wysiwyg.widgets.files.submenu"> + <div class="form-inline align-items-center py-4"> + <input type="file" class="d-none o_file_input" name="upload" t-att-accept="accept" multiple="multiple"/> + <input type="hidden" name="disable_optimization" value=""/> + + <div class="btn-group"> + <button type="button" class="btn btn-primary o_upload_media_button"> + <t t-if="isDocument">Upload a document</t> + <t t-else="">Upload an image</t> + </button> + <t t-if="!isDocument"> + <button class="btn btn-primary dropdown-toggle dropdown-toggle-split o_media_optimization" data-toggle="dropdown" type="button"> + <span class="sr-only">Alternate Upload</span> + </button> + <div class="dropdown-menu" role="menu"> + <button type="button" role="menuitem" class="dropdown-item o_upload_media_button_no_optimization">Upload image without optimization</button> + </div> + </t> + </div> + + <div class="input-group align-items-center ml-2"> + <input type="text" class="form-control o_we_url_input" name="url" t-attf-placeholder="https://www.odoo.com/#{isDocument ? 'mydocument' : 'logo.png'}"/> + <div class="input-group-append align-items-center"> + <button type="button" class="btn btn-secondary o_upload_media_url_button" disabled="disabled"> + <t t-if="isDocument">Add document</t> + <t t-else="">Add image</t> + </button> + <div class="ml-2"> + <span class="o_we_url_success text-success d-none fa fa-lg fa-check" title="The URL seems valid"/> + <span class="o_we_url_warning text-warning d-none fa fa-lg fa-warning" title="The URL does not contain any image. The file will be added in the document section."/> + <span class="o_we_url_error text-danger d-none fa fa-lg fa-times" title="The URL does not seem to work"/> + </div> + </div> + </div> + + <t t-call="wysiwyg.widgets.media.search"> + <t t-set="searchPlaceholder"> + <t t-if="isDocument">Search a document</t> + <t t-else="">Search an image</t> + </t> + </t> + </div> + </t> + + <t t-name="wysiwyg.widgets.image.existing"> + <div role="dialog" class="modal" tabindex="-1"> + <div class="modal-dialog select-image"> + <div class="modal-content"> + <header class="modal-header"> + <h3 class="modal-title">Select a Picture</h3> + <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button> + </header> + <main class="modal-body o_has_error"> + <div class="existing-attachments"/> + <div class="form-text"/> + </main> + <footer class="modal-footer"> + <button data-dismiss="modal" type="button" aria-label="Discard">Discard</button> + </footer> + </div> + </div> + </div> + </t> + <t t-name="wysiwyg.widgets.files.existing.content"> + <div class="existing-attachments"> + <div class="row mt16 ml-auto" t-as="row" t-foreach="rows"> + <div class="col-2 o_existing_attachment_cell" t-as="attachment" t-foreach="row"> + <i t-if="attachment.res_model === 'ir.ui.view'" class="fa fa-trash o_existing_attachment_remove" title="This file is a public view attachment" role="img" aria-label="This file is a public view attachment" t-att-data-id="attachment.id"/> + <i t-else="" class="fa fa-trash o_existing_attachment_remove" title="This file is attached to the current record" role="img" aria-label="This file is attached to the current record" t-att-data-id="attachment.id"/> + <div class="o_attachment_border" t-att-style="attachment.res_model === 'ir.ui.view' ? null : 'border: 1px solid #5cb85c;'"><div t-att-data-src="attachment.src" t-att-data-url="attachment.url" role="img" t-att-aria-label="attachment.name" t-att-title="attachment.name" t-att-data-id="attachment.id" t-att-data-mimetype="attachment.mimetype" t-attf-class="o_image {{withEffect ? 'o_image_loading' : ''}}"/></div> + <t t-if="isDocument"> + <small class="o_file_name"><t t-esc="attachment.name"/></small> + </t> + </div> + </div> + </div> + </t> + <t t-name="wysiwyg.widgets.image.existing.error"> + <div class="form-text"> + <p>The image could not be deleted because it is used in the + following pages or views:</p> + <ul t-as="view" t-foreach="views"> + <li> + <a t-attf-href="/web#model=ir.ui.view&id=#{view.id}"> + <t t-esc="view.name"/> + </a> + </li> + </ul> + </div> + </t> + + <!-- Icon choosing part of the Media Dialog --> + <t t-name="wysiwyg.widgets.font-icons"> + <form action="#"> + <div class="form-inline align-items-center py-4"> + <t t-call="wysiwyg.widgets.media.search"> + <t t-set="searchPlaceholder">Search a pictogram</t> + </t> + </div> + <div class="font-icons-icons"> + <t t-call="wysiwyg.widgets.font-icons.icons"> + <t t-set="iconsParser" t-value="widget.iconsParser"/> + </t> + </div> + </form> + </t> + <t t-name="wysiwyg.widgets.font-icons.icons"> + <t t-as="data" t-foreach="iconsParser"> + <span t-foreach="data.cssData" t-as="cssData" + t-att-data-id="cssData.names[0]" + t-att-title="cssData.names[0]" + t-att-aria-label="cssData.names[0]" role="img" + t-attf-class="font-icons-icon #{data.base} #{cssData.names[0]}" + t-att-data-alias="cssData.names.join(',')"/> + </t> + </t> + + <!-- Video choosing part of the Media Dialog --> + <t t-name="wysiwyg.widgets.video"> + <form action="#" class="row"> + <div class="col mt-4 o_video_dialog_form"> + <div class="form-group mb-2" id="o_video_form_group"> + <label class="col-form-label" for="o_video_text"> + Video code <small>(URL or Embed)</small> + <div class="o_validate_feedback"> + <i class="fa fa-check text-success" role="img" aria-label="Checked" title="Checked"/> + <i class="fa fa-exclamation-triangle text-danger" role="img" aria-label="Attention" title="Attention"/> + </div> + </label> + <textarea class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here"/> + </div> + <div class="text-right"> + <small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Instagram</i></b>, <b><i>Vine.co</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small> + </div> + <div class="o_video_dialog_options d-none mt-4"> + <div class="o_yt_option o_vim_option o_dm_option"> + <label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label> + </div> + <div class="o_yt_option o_vim_option"> + <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label> + </div> + <div class="o_yt_option o_dm_option"> + <label class="o_switch mb0"><input id="o_video_hide_controls" type="checkbox"/><span/>Hide player controls</label> + </div> + <div class="o_yt_option"> + <label class="o_switch mb0"><input id="o_video_hide_fullscreen" type="checkbox"/><span/>Hide fullscreen button</label> + </div> + <div class="o_yt_option"> + <label class="o_switch mb0"><input id="o_video_hide_yt_logo" type="checkbox"/><span/>Hide Youtube logo</label> + </div> + <div class="o_dm_option"> + <label class="o_switch mb0"><input id="o_video_hide_dm_logo" type="checkbox"/><span/>Hide Dailymotion logo</label> + </div> + <div class="o_dm_option"> + <label class="o_switch mb0"><input id="o_video_hide_dm_share" type="checkbox"/><span/>Hide sharing button</label> + </div> + </div> + </div> + <div class="col-md-6"> + <div id="video-preview" class="p-3"> + <div class="o_video_dialog_preview_text small mb-2 d-none">Preview</div> + <div class="media_iframe_video"> + <div class="media_iframe_video_size"/> + <iframe class="o_video_dialog_iframe" allowfullscreen="allowfullscreen" frameborder="0" src=""/> + </div> + </div> + </div> + </form> + </t> + + + <!-- Link Dialog (allows to choose a style and content for a link on the page) --> + <div t-name="wysiwyg.widgets.link" class="o_link_dialog"> + <div class="row"> + <form class="col-lg-8"> + <div t-attf-class="form-group row#{widget.needLabel ? '' : ' d-none'}"> + <label class="col-form-label col-md-3" for="o_link_dialog_label_input">Link Label</label> + <div class="col-md-9"> + <input type="text" name="label" class="form-control" id="o_link_dialog_label_input" required="required" t-att-value="widget.data.text"/> + </div> + </div> + <div class="form-group row"> + <label class="col-form-label col-md-3" for="o_link_dialog_url_input">URL or Email</label> + <div class="col-md-9"> + <input type="text" name="url" class="form-control" id="o_link_dialog_url_input" required="required"/> + </div> + </div> + <div class="form-group row"> + <label class="col-form-label col-md-3">Size</label> + <div class="col-md-9"> + <select name="link_style_size" class="form-control link-style"> + <option value="sm">Small</option> + <option value="" selected="selected">Medium</option> + <option value="lg">Large</option> + </select> + </div> + </div> + <div class="form-group row"> + <label class="col-form-label col-md-3">Style</label> + <div class="col-md-9"> + <select name="link_style_shape" class="form-control link-shape"> + <option value="" selected="selected">Default</option> + <option value="outline">Outline</option> + <option value="rounded-circle">Rounded</option> + <option value="outline,rounded-circle">Outline-Rounded</option> + <option value="flat">Flat</option> + </select> + </div> + </div> + <div class="form-group row"> + <label class="col-form-label col-md-3">Color</label> + <div class="col-md-9"> + <div class="o_link_dialog_color"> + <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-link text-center" data-color=""> + <span>L</span> + <input class="d-none link-style" name="link_style_color" type="radio" value=""/> + <i class="fa"/> + </label> + <t t-foreach="['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'primary', 'secondary', 'success', 'info', 'warning', 'danger']" t-as="color"> + <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-#{color}" t-attf-aria-label="Color for #{color}" t-attf-title="Color for #{color}"> + <input type="radio" name="link_style_color" class="d-none link-style" t-att-value="color"/> + <i class="fa"/> + </label> + </t> + </div> + </div> + </div> + <div class="form-group row"> + <div class="offset-md-3 col-md-9"> + <label class="o_switch"> + <input type="checkbox" name="is_new_window" t-att-checked="widget.data.isNewWindow ? 'checked' : undefined"/> + <span/> + Open in new window + </label> + </div> + </div> + </form> + <div class="col-lg-4 o_link_dialog_preview"> + <div class="form-group text-center"> + <label>Preview</label> + <div style="overflow-x: auto; max-width: 100%; max-height: 200px;"> + <a href="#" id="link-preview" aria-label="Preview" title="Preview"/> + </div> + </div> + </div> + </div> + </div> + + <!-- Crop Image Dialog (allows to crop image on the page) --> + <div t-name="wysiwyg.widgets.crop_image" class="o_crop_image_dialog"> + <div t-if="widget.imageData.isExternalImage" class="alert alert-warning text-center" role="alert"> + <i class="fa fa-exclamation-triangle fa-2x"/> + <h4>This image is an external image</h4> + <p> + This type of image is not supported for cropping.<br/> + If you want to crop it, please first download it from the original source and upload it in Odoo. + </p> + </div> + <t t-else=""> + <div class="o_crop_area"> + <img class="img img-fluid o_cropper_image" t-att-src="widget.imageData.originalSrc"/> + </div> + <div class="o_crop_options text-center mt16"> + <div class="btn-group btn-group-toggle" title="Aspect Ratio" data-toggle="buttons"> + <t t-foreach="widget.aspectRatioList" t-as="ratio"> + <t t-set="is_active" t-value="ratio[1] === widget.imageData.aspectRatio"/> + <label t-attf-class="btn btn-secondary#{is_active and ' active' or ''}" data-event="ratio" t-att-data-label="ratio[1]" t-att-data-value="ratio[2]"> + <input type="radio" /><t t-esc="ratio[0]"/> + </label> + </t> + </div> + <div class="btn-group" role="group"> + <button type="button" class="btn btn-secondary" title="Zoom In" data-event="zoom" data-value="0.1"><i class="fa fa-search-plus"/></button> + <button type="button" class="btn btn-secondary" title="Zoom Out" data-event="zoom" data-value="-0.1"><i class="fa fa-search-minus"/></button> + </div> + <div class="btn-group" role="group"> + <button type="button" class="btn btn-secondary" title="Rotate Left" data-event="rotate" data-value="-45"><i class="fa fa-rotate-left"/></button> + <button type="button" class="btn btn-secondary" title="Rotate Right" data-event="rotate" data-value="45"><i class="fa fa-rotate-right"/></button> + </div> + <div class="btn-group" role="group"> + <button type="button" class="btn btn-secondary" title="Flip Horizontal" data-event="flip" data-value="horizontal" data-x="1"><i class="fa fa-arrows-h"/></button> + <button type="button" class="btn btn-secondary" title="Flip Vertical" data-event="flip" data-value="vertical" data-y="1"><i class="fa fa-arrows-v"/></button> + </div> + <div class="btn-group" role="group"> + <button type="button" class="btn btn-secondary" title="Reset Image" data-event="reset"><i class="fa fa-refresh"/> Reset Image</button> + </div> + </div> + </t> + </div> + +</templates> diff --git a/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml b/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml new file mode 100644 index 00000000000..882a0edea11 --- /dev/null +++ b/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates> + <div t-name="wysiwyg.plugin.font.paletteButton" t-attf-class="note-btn-group btn-group {{className}}"> + <button type="button" class="note-btn btn btn-light btn-sm dropdown-toggle" tabindex="-1" data-toggle="dropdown"><i + t-att-class="icon"></i></button> + <div class="dropdown-menu" /> + </div> + + <div t-name="wysiwyg.plugin.font.colorPalette" class="note-palette"> + <button type="button" class="note-color-reset btn btn-light note-color-btn bg-undefined"> + <t t-esc="lang.color.resetToDefault" /></button> + <div class="note-holder"> + <div class="note-color-palette"> + <h6 class="mt-2">Theme colors</h6> + <div class="o_theme_color_placeholder" /> + <h6 class="mt-2">Transparent colors</h6> + <div class="o_transparent_color_placeholder" /> + <h6 class="mt-2">Common colors</h6> + <div class="o_common_color_placeholder"> + <div class="note-color-row" t-foreach="colors" t-as="rowColors"> + <button t-foreach="rowColors" t-as="color" type="button" class="note-color-btn" data-toggle="button" + tabindex="-1" t-attf-style="background-color:{{color}}" t-att-data-value="color" + t-att-title="color" /> + </div> + </div> + </div> + <h6 class="note-custom-color mt8"> + <t t-esc="lang.color.customColor" /> + </h6> + <button class="note-custom-color-btn note-color-btn" style="display: none;"></button> + </div> + </div> + + <div t-name="wysiwyg.widgets.ColorpickerDialog" class="o_colorpicker_widget"> + <div class="row"> + <div class="col-auto"> + <div class="o_color_pick_area d-inline-block border"> + <div class="o_picker_pointer rounded-circle" /> + </div> + <div class="o_color_slider d-inline-block ml-2 border"> + <div class="o_slider_pointer" /> + </div> + <div class="o_opacity_slider d-inline-block ml-2 border"> + <div class="o_opacity_pointer" /> + </div> + </div> + <form class="col o_color_picker_inputs"> + <div class="input-group"> + <div class="input-group-prepend"> + <span class="input-group-text rounded-0 o_color_preview" /> + </div> + <input type="text" class="form-control o_hex_input" data-color-method="hex" /> + </div> + <div class="form-row mt-2"> + <div class="col"> + <div class="form-group"> + <label for="red">Red</label> + <input type="text" class="form-control o_red_input" data-color-method="rgb" id="red" /> + </div> + <div class="form-group"> + <label for="green">Green</label> + <input type="text" class="form-control o_green_input" data-color-method="rgb" id="green" /> + </div> + <div class="form-group"> + <label for="blue">Blue</label> + <input type="text" class="form-control o_blue_input" data-color-method="rgb" id="blue" /> + </div> + </div> + <div class="col"> + <div class="form-group"> + <label for="hue">Hue</label> + <input type="text" class="form-control o_hue_input" data-color-method="hsl" id="hue" /> + </div> + <div class="form-group"> + <label for="saturation">Saturation %</label> + <input type="text" class="form-control o_saturation_input" data-color-method="hsl" id="saturation" /> + </div> + <div class="form-group"> + <label for="lightness">Lightness %</label> + <input type="text" class="form-control o_lightness_input" data-color-method="hsl" id="lightness" /> + </div> + </div> + </div> + <div class="form-group"> + <label for="opacity">Opacity %</label> + <input type="text" class="form-control o_opacity_input" data-color-method="opacity" id="opacity" /> + </div> + </form> + </div> + </div> +</templates> diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js new file mode 100644 index 00000000000..b01ab48586b --- /dev/null +++ b/addons/web_editor/static/tests/field_html_tests.js @@ -0,0 +1,895 @@ +odoo.define('web_editor.field_html_tests', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var FormView = require('web.FormView'); +var testUtils = require('web.test_utils'); +var weTestUtils = require('web_editor.test_utils'); +var core = require('web.core'); +var Wysiwyg = require('web_editor.wysiwyg'); +var MediaDialog = require('wysiwyg.widgets.MediaDialog'); + +var _t = core._t; + +QUnit.module('web_editor', {}, function () { + +QUnit.module('field html', { + beforeEach: function () { + this.data = weTestUtils.wysiwygData({ + 'note.note': { + fields: { + display_name: { + string: "Displayed name", + type: "char" + }, + body: { + string: "Message", + type: "html" + }, + }, + records: [{ + id: 1, + display_name: "first record", + body: "<p>toto toto toto</p><p>tata</p>", + }], + }, + 'mass.mailing': { + fields: { + display_name: { + string: "Displayed name", + type: "char" + }, + body_html: { + string: "Message Body inline (to send)", + type: "html" + }, + body_arch: { + string: "Message Body for edition", + type: "html" + }, + }, + records: [{ + id: 1, + display_name: "first record", + body_html: "<div class='field_body' style='background-color: red;'>yep</div>", + body_arch: "<div class='field_body'>yep</div>", + }], + }, + }); + + testUtils.mock.patch(ajax, { + loadAsset: function (xmlId) { + if (xmlId === 'template.assets') { + return $.when({ + cssLibs: [], + cssContents: ['body {background-color: red;}'] + }); + } + if (xmlId === 'template.assets_all_style') { + return $.when({ + cssLibs: $('head link[href]:not([type="image/x-icon"])').map(function () { + return $(this).attr('href'); + }).get(), + cssContents: ['body {background-color: red;}'] + }); + } + throw 'Wrong template'; + }, + }); + }, + afterEach: function () { + testUtils.mock.unpatch(ajax); + }, +}, function () { + +QUnit.module('basic'); + +QUnit.test('simple rendering', function (assert) { + var done = assert.async(); + assert.expect(3); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + var $field = form.$('.oe_form_field[name="body"]'); + assert.strictEqual($field.children('.o_readonly').html(), + '<p>toto toto toto</p><p>tata</p>', + "should have rendered a div with correct content in readonly"); + assert.strictEqual($field.attr('style'), 'height: 100px', + "should have applied the style correctly"); + + form.$buttons.find('.o_form_button_edit').click(); + + $field = form.$('.oe_form_field[name="body"]'); + assert.strictEqual($field.find('.note-editable').html(), + '<p>toto toto toto</p><p>tata</p>', + "should have rendered the field correctly in edit"); + + form.destroy(); + done(); + }); +}); + +QUnit.test('colorpicker', function (assert) { + var done = assert.async(); + assert.expect(6); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + form.$buttons.find('.o_form_button_edit').click(); + var $field = form.$('.oe_form_field[name="body"]'); + + // select the text + var pText = $field.find('.note-editable p').first().contents()[0]; + Wysiwyg.setRange(pText, 1, pText, 10); + // text is selected + + var range = Wysiwyg.getRange($field[0]); + assert.strictEqual(range.sc, pText, + "should select the text"); + + $field.find('.note-toolbar .note-bg-color button:first').mousedown().click(); + + assert.ok($field.find('.note-bg-color').hasClass('show') && $field.find('.note-bg-color .dropdown-menu').hasClass('show'), + "should display the color picker"); + + $field.find('.note-toolbar .note-bg-color button[data-value="#00FFFF"]').mousedown().click(); + + assert.ok(!$field.find('.note-bg-color').hasClass('show') && !$field.find('.note-bg-color .dropdown-menu').hasClass('show'), + "should close the color picker"); + + assert.strictEqual($field.find('.note-editable').html(), + '<p>t<font style="background-color: rgb(0, 255, 255);">oto toto </font>toto</p><p>tata</p>', + "should have rendered the field correctly in edit"); + + var fontContent = $field.find('.note-editable font').contents()[0]; + var rangeControl = { + sc: fontContent, + so: 0, + ec: fontContent, + eo: fontContent.length, + }; + range = Wysiwyg.getRange($field[0]); + assert.deepEqual(_.pick(range, 'sc', 'so', 'ec', 'eo'), rangeControl, + "should select the text after color change"); + + // select the text + pText = $field.find('.note-editable p').first().contents()[2]; + Wysiwyg.setRange(fontContent, 5, pText, 2); + // text is selected + + $field.find('.note-toolbar .note-bg-color button:first').mousedown().click(); + $field.find('.note-toolbar .note-bg-color button[data-value="bg-gamma"]').mousedown().click(); + + assert.strictEqual($field.find('.note-editable').html(), + '<p>t<font style="background-color: rgb(0, 255, 255);">oto t</font><font class="bg-gamma">oto to</font>to</p><p>tata</p>', + "should have rendered the field correctly in edit"); + + form.destroy(); + done(); + }); +}); + +QUnit.test('media dialog: image', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.model === 'ir.attachment') { + if (args.method === "generate_access_token") { + return $.when(); + } + if (args.kwargs.domain[7][2].join(',') === "image/gif,image/jpe,image/jpeg,image/jpg,image/gif,image/png") { + return $.when([{ + "id": 1, + "public": true, + "name": "image", + "datas_fname": "image.png", + "mimetype": "image/png", + "checksum": false, + "url": "/web_editor/static/src/img/transparent.png", + "type": "url", + "res_id": 0, + "res_model": false, + "access_token": false + }]); + } + } + return this._super(route, args); + }, + }).then(function (form) { + form.$buttons.find('.o_form_button_edit').click(); + var $field = form.$('.oe_form_field[name="body"]'); + + // the dialog load some xml assets + var defMediaDialog = $.Deferred(); + testUtils.mock.patch(MediaDialog, { + init: function () { + this._super.apply(this, arguments); + this.opened(defMediaDialog.resolve.bind(defMediaDialog)); + } + }); + + var pText = $field.find('.note-editable p').first().contents()[0]; + Wysiwyg.setRange(pText, 1); + + $field.find('.note-toolbar .note-insert button:has(.fa-file-image-o)').mousedown().click(); + + // load static xml file (dialog, media dialog, unsplash image widget) + defMediaDialog.then(function () { + $('.modal #editor-media-image .o_image:first').click(); + $('.modal .modal-footer button.btn-primary').click(); + + var $editable = form.$('.oe_form_field[name="body"] .note-editable'); + + assert.strictEqual($editable.data('wysiwyg').getValue(), + '<p>t<img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png">oto toto toto</p><p>tata</p>', + "should have the image in the dom"); + + testUtils.mock.unpatch(MediaDialog); + + form.destroy(); + done(); + }); + }); +}); + +QUnit.test('media dialog: icon', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.model === 'ir.attachment') { + return $.when([]); + } + return this._super(route, args); + }, + }).then(function (form) { + form.$buttons.find('.o_form_button_edit').click(); + var $field = form.$('.oe_form_field[name="body"]'); + + // the dialog load some xml assets + var defMediaDialog = $.Deferred(); + testUtils.mock.patch(MediaDialog, { + init: function () { + this._super.apply(this, arguments); + this.opened(defMediaDialog.resolve.bind(defMediaDialog)); + } + }); + + var pText = $field.find('.note-editable p').first().contents()[0]; + Wysiwyg.setRange(pText, 1); + + $field.find('.note-toolbar .note-insert button:has(.fa-file-image-o)').mousedown().click(); + + // load static xml file (dialog, media dialog, unsplash image widget) + defMediaDialog.then(function () { + $('.modal .tab-content .tab-pane').removeClass('fade'); // to be sync in test + $('.modal a[aria-controls="editor-media-icon"]').click(); + $('.modal #editor-media-icon .font-icons-icon.fa-glass').click(); + $('.modal .modal-footer button.btn-primary').click(); + + var $editable = form.$('.oe_form_field[name="body"] .note-editable'); + + assert.strictEqual($editable.data('wysiwyg').getValue(), + '<p>t<span class="fa fa-glass"></span>oto toto toto</p><p>tata</p>', + "should have the image in the dom"); + + testUtils.mock.unpatch(MediaDialog); + + form.destroy(); + done(); + }, 200); + }); +}); + +QUnit.test('save', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === "write") { + assert.strictEqual(args.args[1].body, + '<p>t<font class="bg-gamma">oto toto </font>toto</p><p>tata</p>', + "should save the content"); + + } + return this._super.apply(this, arguments); + }, + }).then(function (form) { + form.$buttons.find('.o_form_button_edit').click(); + var $field = form.$('.oe_form_field[name="body"]'); + + // select the text + var pText = $field.find('.note-editable p').first().contents()[0]; + Wysiwyg.setRange(pText, 1, pText, 10); + // text is selected + + $field.find('.note-toolbar .note-bg-color button:first').mousedown().click(); + $field.find('.note-toolbar .note-bg-color button[data-value="bg-gamma"]').mousedown().click(); + + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + done(); + }); +}); + +QUnit.module('inline-style'); + +QUnit.test('convert style to class on edit', function (assert) { + var done = assert.async(); + assert.expect(5); + + this.data['note.note'].records[0].body = '<p class="pull-right" style="float: right;">toto ' + + '<img data-class="fa fa-star" data-style="color: red;" src="/web_editor/font_to_img/61445/rgb(255,0,0)/13" style="border-style:none;vertical-align:middle;height: auto; width: auto;">' + + 'toto toto</p><p>tata</p>'; + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'style-inline\': true}"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === "write") { + assert.strictEqual(args.args[1].body, + '<p class="pull-right" style="margin:0px;font-size:13px;font-family:"Lucida Grande", Helvetica, Verdana, Arial, sans-serif;float:right;">' + + 'toto ' + + '<img ' + + 'data-class="fa fa-star" ' + + 'data-style="color: red;" ' + + 'style="border-style:none;vertical-align:middle;color: red; height: auto; width: auto;" ' + + 'data-src="/web_editor/font_to_img/61445/rgb(255,0,0)/13"' + + '>' + + 'toto toto' + + '</p>' + + '<p style="margin:0px;font-size:13px;font-family:"Lucida Grande", Helvetica, Verdana, Arial, sans-serif;">' + + 'tata' + + '</p>', + "should save the content"); + } + if (route.indexOf("/web_editor/font_to_img/61445/rgb(") !== -1) { + assert.strictEqual(route, + '/web_editor/font_to_img/61445/rgb(255,0,0)/13', + "should use the image in function of the font and the color"); + return $.when('#'); + } + return this._super.apply(this, arguments); + }, + }).then(function (form) { + var $field = form.$('.oe_form_field[name="body"]'); + + assert.strictEqual($field.children('.o_readonly').html(), + '<p class="pull-right" style="float: right;">' + + 'toto ' + + '<img data-class="fa fa-star" data-style="color: red;" ' + + 'style="border-style:none;vertical-align:middle;height: auto; width: auto;" ' + + 'data-src="/web_editor/font_to_img/61445/rgb(255,0,0)/13">' + + 'toto toto' + + '</p>' + + '<p>tata</p>', + "should have rendered a div with correct content in readonly"); + + form.$buttons.find('.o_form_button_edit').click(); + + $field = form.$('.oe_form_field[name="body"]'); + assert.strictEqual($field.find('.note-editable').html(), + '<p class="pull-right">' + + 'toto ' + + '<span class="fa fa-star o_fake_not_editable" style="color: red;" contenteditable="false"></span>' + + 'toto toto' + + '</p>' + + '<p>tata</p>', + "should have rendered the field correctly in edit (remove inline style that used class)"); + + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + done(); + }); +}); + +QUnit.module('cssReadonly'); + +QUnit.test('rendering with iframe for readonly mode', function (assert) { + var done = assert.async(); + assert.expect(3); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'cssReadonly\': \'template.assets\'}"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + var $field = form.$('.oe_form_field[name="body"]'); + var $iframe = $field.find('iframe.o_readonly'); + $iframe.data('load-def').then(function () { + var doc = $iframe.contents()[0]; + assert.strictEqual($(doc).find('#iframe_target').html(), + '<p>toto toto toto</p><p>tata</p>', + "should have rendered a div with correct content in readonly"); + + assert.strictEqual(doc.defaultView.getComputedStyle(doc.body).backgroundColor, + 'rgb(255, 0, 0)', + "should load the asset css"); + + form.$buttons.find('.o_form_button_edit').click(); + + $field = form.$('.oe_form_field[name="body"]'); + assert.strictEqual($field.find('.note-editable').html(), + '<p>toto toto toto</p><p>tata</p>', + "should have rendered the field correctly in edit"); + + form.destroy(); + done(); + }); + }); +}); + +QUnit.module('cssEdit'); + +QUnit.test('rendering with iframe for edit mode', function (assert) { + var done = assert.async(); + assert.expect(4); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'cssEdit\': \'template.assets\'}"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + var $field = form.$('.oe_form_field[name="body"]'); + assert.strictEqual($field.children('.o_readonly').html(), + '<p>toto toto toto</p><p>tata</p>', + "should have rendered a div with correct content in readonly"); + + form.$buttons.find('.o_form_button_edit').click(); + + $field = form.$('.oe_form_field[name="body"]'); + var $iframe = $field.find('iframe'); + + $iframe.data('load-def').then(function () { + var doc = $iframe.contents()[0]; + var $content = $('#iframe_target', doc); + + assert.strictEqual($content.find('.note-editable').html(), + '<p>toto toto toto</p><p>tata</p>', + "should have rendered a div with correct content in edit mode"); + + assert.strictEqual(doc.defaultView.getComputedStyle(doc.body).backgroundColor, + 'rgb(255, 0, 0)', + "should load the asset css"); + + $content.find('.note-toolbar .note-bg-color button:first').mousedown().click(); + + assert.ok($content.find('.note-bg-color').hasClass('show') && $content.find('.note-bg-color .dropdown-menu').hasClass('show'), + "should display toolbar dropdown menu in iframe"); + + form.destroy(); + done(); + }); + }); +}); + +QUnit.test('use colorpicker and save', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'cssEdit\': \'template.assets\'}"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === "write") { + assert.strictEqual(args.args[1].body, + '<p>t<font class="bg-gamma">oto toto </font>toto</p><p>tata</p>', + "should save the content"); + } + return this._super.apply(this, arguments); + }, + }).then(function (form) { + form.$buttons.find('.o_form_button_edit').click(); + var $field = form.$('.oe_form_field[name="body"]'); + var $iframe = $field.find('iframe'); + + $iframe.data('load-def').then(function () { + var doc = $iframe.contents()[0]; + var $content = $('#iframe_target', doc); + var $editable = $content.find('.note-editable'); + + // select the text + var pText = $editable.find('p').first().contents()[0]; + Wysiwyg.setRange(pText, 1, pText, 10); + // text is selected + + $content.find('.note-toolbar .note-bg-color button:first').mousedown().click(); + $content.find('.note-toolbar .note-bg-color button[data-value="bg-gamma"]').mousedown().click(); + + + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + done(); + }); + }); +}); + +QUnit.module('wrapper'); + +// todo add test wrapper + +QUnit.module('snippet (without iframe)'); + +QUnit.test('rendering with snippet panel', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'snippets\': \'web_editor.snippets\'}"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + testUtils.mock.intercept(form, "snippets_loaded", function () { + assert.strictEqual(form.$('.oe_form_field #oe_snippets').length, 1, + "should display the snippet panel"); + form.destroy(); + done(); + }); + form.$buttons.find('.o_form_button_edit').click(); + }); +}); + +QUnit.test('drag&drop snippet', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'snippets\': \'web_editor.snippets\'}"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + var defSnippets = $.Deferred(); + testUtils.mock.intercept(form, "snippets_loaded", function () { + defSnippets.resolve(); + }); + + form.$buttons.find('.o_form_button_edit').click(); + var $field = form.$('.oe_form_field[name="body"]'); + var $editable = $field.find('.note-editable'); + + defSnippets.then(function () { + var $hr = $field.find('.oe_snippet_thumbnail:first'); + testUtils.dom.dragAndDrop($hr, $editable.find('p')); + + assert.strictEqual($editable.data('wysiwyg').getValue().replace(/\s+/g, ' '), + '<div class=\"s_hr pt32 pb32\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> </div><p>toto toto toto</p><p>tata</p>', + "should drop the snippet"); + + form.destroy(); + done(); + }); + }); +}); + +// todo add test snippet + customize snippet + +QUnit.module('snippet & cssEdit'); + +QUnit.test('rendering with snippet panel', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'snippets\': \'web_editor.snippets\', \'cssEdit\': \'template.assets\'}"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + testUtils.mock.intercept(form, "snippets_loaded", function () { + var doc = form.$('iframe').contents()[0]; + var $content = $('#iframe_target', doc); + assert.strictEqual($content.find('#oe_snippets').length, 1, + "should display the snippet panel"); + form.destroy(); + done(); + }); + form.$buttons.find('.o_form_button_edit').click(); + }); +}); + +QUnit.test('drag&drop snippet', function (assert) { + var done = assert.async(); + assert.expect(1); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'snippets\': \'web_editor.snippets\', \'cssEdit\': \'template.assets_all_style\'}"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + var defSnippets = $.Deferred(); + testUtils.mock.intercept(form, "snippets_loaded", function () { + defSnippets.resolve(); + }); + form.$buttons.find('.o_form_button_edit').click(); + + defSnippets.then(function () { + var doc = form.$('iframe').contents()[0]; + var $content = $('#iframe_target', doc); + var $editable = $content.find('.note-editable'); + + var $hr = $content.find('.oe_snippet_thumbnail:first'); + var from = $hr.offset(); + var to = $editable.find('p').offset(); + + $hr.trigger($.Event("mousedown", { + which: 1, + pageX: from.left + 1, + pageY: from.top + 1 + })); + $hr.trigger($.Event("mousemove", { + which: 1, + pageX: to.left, + pageY: to.top + })); + $hr.trigger($.Event("mouseup", { + which: 1, + pageX: to.left, + pageY: to.top + })); + + assert.strictEqual($editable.data('wysiwyg').getValue().replace(/\s+/g, ' '), + '<div class=\"s_hr pt32 pb32\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> </div><p>toto toto toto</p><p>tata</p>', + "should drop the snippet"); + + form.destroy(); + done(); + }); + }); +}); + +QUnit.test('drag&drop snippet options', function (assert) { + var done = assert.async(); + assert.expect(6); + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'snippets\': \'web_editor.snippets\', \'cssEdit\': \'template.assets_all_style\'}"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + var defSnippets = $.Deferred(); + testUtils.mock.intercept(form, "snippets_loaded", function () { + defSnippets.resolve(); + }); + + var defActivateSnippet; + testUtils.mock.intercept(form, "snippet_focused", function (e) { + defActivateSnippet.resolve(e.target); + }); + + form.$buttons.find('.o_form_button_edit').click(); + + defSnippets.then(function () { + var doc = form.$('iframe').contents()[0]; + var $content = $('#iframe_target', doc); + var $editable = $content.find('.note-editable'); + + function dropSnippet() { + defActivateSnippet = $.Deferred(); + + var $hr = $content.find('.oe_snippet_thumbnail:first'); + var from = $hr.offset(); + var to = $editable.find('p').offset(); + + $hr.trigger($.Event("mousedown", { + which: 1, + pageX: from.left + 1, + pageY: from.top + 1 + })); + $hr.trigger($.Event("mousemove", { + which: 1, + pageX: to.left, + pageY: to.top + })); + $hr.trigger($.Event("mouseup", { + which: 1, + pageX: to.left, + pageY: to.top + })); + } + + dropSnippet(); + defActivateSnippet.then(function (snippet) { + assert.strictEqual($editable.data('wysiwyg').getValue().replace(/\s+/g, ' '), + '<div class=\"s_hr pt32 pb32 built focus\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> </div><p>toto toto toto</p><p>tata</p>', + "should drop the snippet"); + + assert.strictEqual(snippet.$target.index(), 0, + 'should show the snippet editor for the first dropped block'); + + dropSnippet(); + defActivateSnippet.then(function (snippet) { + assert.strictEqual($editable.data('wysiwyg').getValue().replace(/\s+/g, ' '), + '<div class=\"s_hr pt32 pb32 built\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> ' + + '</div><div class=\"s_hr pt32 pb32 built focus\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> ' + + '</div><p>toto toto toto</p><p>tata</p>', + "should drop the snippet"); + + assert.strictEqual(snippet.$target.index(), 1, + 'should show the snippet editor for the second dropped block'); + + assert.strictEqual($('.oe_active .oe_overlay_options .btn:visible', doc).length, 4, + 'should show the snippet editor buttons'); + + defActivateSnippet = $.Deferred(); + $editable.find('hr:first').trigger('click'); + defActivateSnippet.then(function (snippet) { + + assert.strictEqual(snippet.$target.index(), 0, + 'should show the snippet editor for the first dropped block'); + + form.destroy(); + done(); + }); + }); + }); + }); + }); +}); + +QUnit.module('translation'); + +QUnit.test('field html translatable', function (assert) { + assert.expect(3); + + var multiLang = _t.database.multi_lang; + _t.database.multi_lang = true; + + this.data['note.note'].fields.body.translate = true; + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form string="Partners">' + + '<field name="body" widget="html"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_button' && args.method === 'translate_fields') { + assert.deepEqual(args.args, ['note.note', 1, 'body', {}], "should call 'call_button' route"); + return $.when(); + } + return this._super.apply(this, arguments); + }, + }).then(function (form) { + assert.strictEqual(form.$('.oe_form_field_html .o_field_translate').length, 0, + "should not have a translate button in readonly mode"); + + form.$buttons.find('.o_form_button_edit').click(); + var $button = form.$('.oe_form_field_html .note-toolbar .o_field_translate'); + assert.strictEqual($button.length, 1, "should have a translate button"); + $button.click(); + + form.destroy(); + _t.database.multi_lang = multiLang; + + }); +}); + +QUnit.test('field html translatable in iframe', function (assert) { + var done = assert.async(); + assert.expect(2); + + var multiLang = _t.database.multi_lang; + _t.database.multi_lang = true; + + this.data['note.note'].fields.body.translate = true; + + testUtils.createAsyncView({ + View: FormView, + model: 'note.note', + data: this.data, + arch: '<form>' + + '<field name="body" widget="html" style="height: 100px" options="{\'cssEdit\': \'template.assets\'}"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_button' && args.method === 'translate_fields') { + assert.deepEqual(args.args, ['note.note', 1, 'body', {}], "should call 'call_button' route"); + return $.when(); + } + return this._super.apply(this, arguments); + }, + }).then(function (form) { + var $field = form.$('.oe_form_field[name="body"]'); + form.$buttons.find('.o_form_button_edit').click(); + $field = form.$('.oe_form_field[name="body"]'); + var $iframe = $field.find('iframe'); + + $iframe.data('load-def').then(function () { + var doc = $iframe.contents()[0]; + var $content = $('#iframe_target', doc); + + var $button = $content.find('.note-toolbar .o_field_translate'); + assert.strictEqual($button.length, 1, "should have a translate button"); + $button.click(); + + form.destroy(); + _t.database.multi_lang = multiLang; + done(); + }); + }); +}); + +}); +}); +}); diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js new file mode 100644 index 00000000000..ff740e52f36 --- /dev/null +++ b/addons/web_editor/static/tests/test_utils.js @@ -0,0 +1,674 @@ +odoo.define('web_editor.test_utils', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); +var Wysiwyg = require('web_editor.wysiwyg'); +var options = require('web_editor.snippets.options'); + + +/** + * Options with animation and edition for test. + */ +options.registry.option_test = options.Class.extend({ + cleanForSave: function () { + this.$target.addClass('cleanForSave'); + }, + onBuilt: function () { + this.$target.addClass('built'); + }, + onBlur: function () { + this.$target.removeClass('focus'); + }, + onClone: function () { + this.$target.addClass('clone'); + this.$target.removeClass('focus'); + }, + onFocus: function () { + this.$target.addClass('focus'); + }, + onMove: function () { + this.$target.addClass('move'); + }, + onRemove: function () { + this.$target.closest('.note-editable').addClass('snippet_has_removed'); + }, +}); + + +/** + * Constructor WysiwygTest why editable and unbreakable node used in test. + */ +var WysiwygTest = Wysiwyg.extend({ + _parentToDestroyForTest: null, + /** + * Override 'destroy' of discuss so that it calls 'destroy' on the parent. + * + * @override + */ + destroy: function () { + unpatch(); + this._super(); + this.$target.remove(); + this._parentToDestroyForTest.destroy(); + }, + /** + * @override + */ + isUnbreakableNode: function (node) { + var Node = (node.tagName ? node : node.parentNode); + return (!this.options.useOnlyTestUnbreakable && this._super(node)) || + !this.isEditableNode(node) || + Node.tagName === "UNBREAKABLE" || + (Node.className + '').indexOf('unbreakable') !== -1; + }, + /** + * @override + */ + isEditableNode: function (node) { + if (!$(node).closest(this.$el).length) { + return false; + } + while (node) { + if (node.tagName === "EDITABLE") { + return true; + } + if (node.tagName === "NOTEDITABLE") { + return false; + } + if ((node.className + '').indexOf('editable') !== -1) { + return true; + } + if (this.$el[0] === node) { + return true; + } + node = node.parentNode; + } + return false; + }, +}); + + +function patch() { + testUtils.mock.patch(ajax, { + loadAsset: function (xmlId) { + if (xmlId === 'template.assets') { + return $.when({ + cssLibs: [], + cssContents: ['body {background-color: red;}'] + }); + } + if (xmlId === 'template.assets_all_style') { + return $.when({ + cssLibs: $('head link[href]:not([type="image/x-icon"])').map(function () { + return $(this).attr('href'); + }).get(), + cssContents: ['body {background-color: red;}'] + }); + } + throw 'Wrong template'; + }, + }); +} + +function unpatch() { + testUtils.mock.unpatch(ajax); +} + +/** + * @param {object} data + * @returns {object} + */ +function wysiwygData(data) { + return _.defaults({}, data, { + 'ir.ui.view': { + fields: { + display_name: { + string: "Displayed name", + type: "char", + }, + }, + records: [], + read_template: function (args) { + if (args[0] === 'web_editor.colorpicker') { + return '<templates><t t-name="web_editor.colorpicker">' + + '<colorpicker>' + + ' <div class="o_colorpicker_section" data-name="theme" data-display="Theme Colors" data-icon-class="fa fa-flask">' + + ' <button data-color="alpha"></button>' + + ' <button data-color="beta"></button>' + + ' <button data-color="gamma"></button>' + + ' <button data-color="delta"></button>' + + ' <button data-color="epsilon"></button>' + + ' </div>' + + ' <div class="o_colorpicker_section" data-name="transparent_grayscale" data-display="Transparent Colors" data-icon-class="fa fa-eye-slash">' + + ' <button class="o_btn_transparent"></button>' + + ' <button data-color="black-25"></button>' + + ' <button data-color="black-50"></button>' + + ' <button data-color="black-75"></button>' + + ' <button data-color="white-25"></button>' + + ' <button data-color="white-50"></button>' + + ' <button data-color="white-75"></button>' + + ' </div>' + + ' <div class="o_colorpicker_section" data-name="common" data-display="Common Colors" data-icon-class="fa fa-paint-brush"></div>' + + '</colorpicker>' + + '</t></templates>'; + } + }, + render_template: function (args) { + if (args[0] === 'web_editor.snippets') { + return '<h2 id="snippets_menu">Add blocks</h2>' + + '<div id="o_scroll">' + + ' <div id="snippet_structure" class="o_panel">' + + ' <div class="o_panel_header">' + + ' <i class="fa fa-th-large"></i> First Panel' + + ' </div>' + + ' <div class="o_panel_body">' + + ' <div name="Separator" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png">' + + ' <div class="s_hr pt32 pb32">' + + ' <hr class="s_hr_1px s_hr_solid w-100 mx-auto"/>' + + ' </div>' + + ' </div>' + + ' <div name="Content" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">' + + ' <section name="Content+Options" class="test_option_all pt32 pb32" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">' + + ' <div class="container">' + + ' <div class="row">' + + ' <div class="col-lg-10 offset-lg-1 pt32 pb32">' + + ' <h2>Title</h2>' + + ' <p class="lead o_default_snippet_text">Content</p>' + + ' </div>' + + ' </div>' + + ' </div>' + + ' </section>' + + ' </div>' + + ' </div>' + + ' </div>' + + '</div>' + + '<div id="snippet_options" class="d-none">' + + ' <div data-js="many2one" data-selector="[data-oe-many2one-model]:not([data-oe-readonly])" data-no-check="true"></div>' + + ' <div data-js="content" data-selector=".s_hr, .test_option_all" ' + + 'data-drop-in=".note-editable" data-drop-near="p, h1, h2, h3, blockquote, .s_hr"></div>' + + ' <div data-js="sizing_y" data-selector=".s_hr, .test_option_all"></div>' + + ' <div data-js="colorpicker" data-selector=".test_option_all">' + + ' <div class="dropdown-submenu">' + + ' <a tabindex="-1" href="#" class="dropdown-item"><i class="fa fa-eyedropper"/>Background Color</a>' + + ' <div class="dropdown-menu" role="menu"/>' + + ' </div>' + + ' </div>' + + ' <div data-js="background" data-selector=".test_option_all">' + + ' <a tabindex="-1" href="#" class="dropdown-item" data-choose-image="true" data-no-preview="true"><i class="fa fa-picture-o"/>Background Image</a>' + + ' </div>' + + ' <div data-js="option_test" data-selector=".s_hr">' + + ' <div class="dropdown-submenu">' + + ' <a tabindex="-2" href="#" class="dropdown-item"><i class="fa fa-arrows-v"/>Alignment</a>' + + ' <div class="dropdown-menu" role="menu">' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-start">Top</a>' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-center">Middle</a>' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-end">Bottom</a>' + + ' <div class="dropdown-divider"/>' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-stretch">Equal height</a>' + + ' </div>' + + ' </div>' + + ' </div>' + + '</div>'; + } + }, + }, + 'ir.attachment': { + fields: { + display_name: { + string: "display_name", + type: 'char', + }, + datas_fname: { + string: "datas_fname", + type: 'char', + }, + mimetype: { + string: "mimetype", + type: 'char', + }, + checksum: { + string: "checksum", + type: 'char', + }, + url: { + string: "url", + type: 'char', + }, + type: { + string: "type", + type: 'char', + }, + res_id: { + string: "res_id", + type: 'integer', + }, + res_model: { + string: "res_model", + type: 'char', + }, + access_token: { + string: "access_token", + type: 'char', + }, + }, + records: [{ + id: 1, + public: true, + name: 'image', + datas_fname: 'image.png', + mimetype: 'image/png', + checksum: false, + url: '/web_editor/static/src/img/transparent.png', + type: 'url', + res_id: 0, + res_model: false, + access_token: false + }], + generate_access_token: function () { + return; + }, + }, + }); +} + +/** + * Create the wysiwyg instance for test (contains patch, usefull ir.ui.view, snippets). + * + * @param {object} params + */ +function createWysiwyg(params) { + patch(); + params.data = wysiwygData(params.data); + + var parent = new Widget(); + testUtils.mock.addMockEnvironment(parent, params); + + var wysiwygOptions = _.extend({}, params.wysiwygOptions, { + recordInfo: { + context: {}, + res_model: 'module.test', + res_id: 1, + }, + useOnlyTestUnbreakable: params.useOnlyTestUnbreakable, + }); + + var wysiwyg = new WysiwygTest(parent, wysiwygOptions); + wysiwyg._parentToDestroyForTest = parent; + + var $textarea = $('<textarea/>'); + if (wysiwygOptions.value) { + $textarea.val(wysiwygOptions.value); + } + var selector = params.debug ? 'body' : '#qunit-fixture'; + $textarea.prependTo($(selector)); + if (params.debug) { + $('body').addClass('debug'); + } + return wysiwyg.attachTo($textarea).then(function () { + if (wysiwygOptions.snippets) { + var defSnippets = $.Deferred(); + testUtils.mock.intercept(wysiwyg, "snippets_loaded", function () { + defSnippets.resolve(wysiwyg); + }); + return defSnippets; + } + return wysiwyg; + }); +} + + +/** + * Char codes. + */ +var dom = $.summernote.dom; +var keyboardMap = { + "8": "BACKSPACE", + "9": "TAB", + "13": "ENTER", + "16": "SHIFT", + "17": "CONTROL", + "18": "ALT", + "19": "PAUSE", + "20": "CAPS_LOCK", + "27": "ESCAPE", + "32": "SPACE", + "33": "PAGE_UP", + "34": "PAGE_DOWN", + "35": "END", + "36": "HOME", + "37": "LEFT", + "38": "UP", + "39": "RIGHT", + "40": "DOWN", + "45": "INSERT", + "46": "DELETE", + "91": "OS_KEY", // 'left command': Windows Key (Windows) or Command Key (Mac) + "93": "CONTEXT_MENU", // 'right command' +}; +_.each(_.range(40, 127), function (keyCode) { + if (!keyboardMap[keyCode]) { + keyboardMap[keyCode] = String.fromCharCode(keyCode); + } +}); + +/** + * Perform a series of tests (`keyboardTests`) for using keyboard inputs. + * + * @see wysiwyg_keyboard_tests.js + * @see wysiwyg_tests.js + * + * @param {jQuery} $editable + * @param {object} assert + * @param {object[]} keyboardTests + * @param {string} keyboardTests.name + * @param {string} keyboardTests.content + * @param {object[]} keyboardTests.steps + * @param {string} keyboardTests.steps.start + * @param {string} [keyboardTests.steps.end] default: steps.start + * @param {string} keyboardTests.steps.key + * @param {object} keyboardTests.test + * @param {string} [keyboardTests.test.content] + * @param {string} [keyboardTests.test.start] + * @param {string} [keyboardTests.test.end] default: steps.start + * @param {function($editable, assert)} [keyboardTests.test.check] + * @param {Number} addTests + */ +var testKeyboard = function ($editable, assert, keyboardTests, addTests) { + var tests = _.compact(_.pluck(keyboardTests, 'test')); + var testNumber = _.compact(_.pluck(tests, 'start')).length + + _.compact(_.pluck(tests, 'content')).length + + _.compact(_.pluck(tests, 'check')).length + + (addTests | 0); + assert.expect(testNumber); + + function keydown(target, keypress) { + var $target = $(target.tagName ? target : target.parentNode); + if (!keypress.keyCode) { + keypress.keyCode = +_.findKey(keyboardMap, function (key) { + return key === keypress.key; + }); + } else { + keypress.key = keyboardMap[keypress.keyCode] || String.fromCharCode(keypress.keyCode); + } + keypress.keyCode = keypress.keyCode; + var event = $.Event("keydown", keypress); + $target.trigger(event); + if (!event.isDefaultPrevented()) { + if (keypress.key.length === 1) { + document.execCommand("insertText", 0, keypress.key); + } else { + console.warn('Native "' + keypress.key + '" is not supported in test'); + } + } + $target.trigger($.Event("keyup", keypress)); + return $target; + } + + function _select(selector) { + // eg: ".class:contents()[0]->1" selects the first contents of the 'class' class, with an offset of 1 + var reDOMSelection = /^(.+?)(:contents(\(\)\[|\()([0-9]+)[\]|\)])?(->([0-9]+))?$/; + var sel = selector.match(reDOMSelection); + var $node = $editable.find(sel[1]); + var point = { + node: sel[3] ? $node.contents()[+sel[4]] : $node[0], + offset: sel[5] ? +sel[6] : 0, + }; + if (!point.node || point.offset > (point.node.tagName ? point.node.childNodes : point.node.textContent).length) { + assert.notOk("Node not found: '" + selector + "' " + (point.node ? "(container: '" + (point.node.outerHTML || point.node.textContent) + "')" : "")); + } + return point; + } + + function selectText(start, end) { + start = _select(start); + var target = start.node; + $(target.tagName ? target : target.parentNode).trigger("mousedown"); + if (end) { + end = _select(end); + Wysiwyg.setRange(start.node, start.offset, end.node, end.offset); + } else { + Wysiwyg.setRange(start.node, start.offset); + } + target = end ? end.node : start.node; + $(target.tagName ? target : target.parentNode).trigger('mouseup'); + } + + function endOfAreaBetweenTwoNodes(point) { + // move the position because some browser make the caret on the end of the previous area after normalize + if ( + !point.node.tagName && + point.offset === point.node.textContent.length && + !/\S|\u00A0/.test(point.node.textContent) + ) { + point = dom.nextPoint(dom.nextPoint(point)); + while (point.node.tagName && point.node.textContent.length) { + point = dom.nextPoint(point); + } + } + return point; + } + + var defPollTest = $.when(); + + function pollTest(test) { + var def = $.when(); + $editable.data('wysiwyg').setValue(test.content); + + function poll(step) { + var def = $.Deferred(); + if (step.start) { + selectText(step.start, step.end); + if (!Wysiwyg.getRange($editable[0])) { + throw 'Wrong range! \n' + + 'Test: ' + test.name + '\n' + + 'Selection: ' + step.start + '" to "' + step.end + '"\n' + + 'DOM: ' + $editable.html(); + } + } + setTimeout(function () { + if (step.keyCode || step.key) { + var target = Wysiwyg.getRange($editable[0]).ec; + if (window.location.search.indexOf('notrycatch') !== -1) { + keydown(target, { + key: step.key, + keyCode: step.keyCode, + ctrlKey: !!step.ctrlKey, + shiftKey: !!step.shiftKey, + altKey: !!step.altKey, + metaKey: !!step.metaKey, + }); + } else { + try { + keydown(target, { + key: step.key, + keyCode: step.keyCode, + ctrlKey: !!step.ctrlKey, + shiftKey: !!step.shiftKey, + altKey: !!step.altKey, + metaKey: !!step.metaKey, + }); + } catch (e) { + assert.notOk(e.name + '\n\n' + e.stack, test.name); + } + } + } + setTimeout(function () { + if (step.keyCode || step.key) { + var $target = $(target.tagName ? target : target.parentNode); + $target.trigger($.Event('keyup', { + key: step.key, + keyCode: step.keyCode, + ctrlKey: !!step.ctrlKey, + shiftKey: !!step.shiftKey, + altKey: !!step.altKey, + metaKey: !!step.metaKey, + })); + } + setTimeout(def.resolve.bind(def)); + }); + }); + return def; + } + while (test.steps.length) { + def = def.then(poll.bind(null, test.steps.shift())); + } + + return def.then(function () { + if (!test.test) { + return; + } + + if (test.test.check) { + test.test.check($editable, assert); + } + + // test content + if (test.test.content) { + var value = $editable.data('wysiwyg').getValue({ + keepPopover: true, + }); + var allInvisible = /\u200B/g; + value = value.replace(allInvisible, '​'); + var result = test.test.content.replace(allInvisible, '​'); + assert.strictEqual(value, result, test.name); + + if (test.test.start && value !== result) { + assert.notOk("Wrong DOM (see previous assert)", test.name + " (carret position)"); + return; + } + } + + $editable[0].normalize(); + + // test carret position + if (test.test.start) { + var start = _select(test.test.start); + var range = Wysiwyg.getRange($editable[0]); + if ((range.sc !== range.ec || range.so !== range.eo) && !test.test.end) { + assert.ok(false, test.name + ": the carret is not colapsed and the 'end' selector in test is missing"); + return; + } + var end = test.test.end ? _select(test.test.end) : start; + if (start.node && end.node) { + range = Wysiwyg.getRange($editable[0]); + var startPoint = endOfAreaBetweenTwoNodes({ + node: range.sc, + offset: range.so, + }); + var endPoint = endOfAreaBetweenTwoNodes({ + node: range.ec, + offset: range.eo, + }); + var sameDOM = (startPoint.node.outerHTML || startPoint.node.textContent) === (start.node.outerHTML || start.node.textContent); + var stringify = function (obj) { + if (!sameDOM) { + delete obj.sameDOMsameNode; + } + return JSON.stringify(obj, null, 2) + .replace(/"([^"\s-]+)":/g, "\$1:") + .replace(/([^\\])"/g, "\$1'") + .replace(/\\"/g, '"'); + }; + assert.deepEqual(stringify({ + startNode: startPoint.node.outerHTML || startPoint.node.textContent, + startOffset: startPoint.offset, + endPoint: endPoint.node.outerHTML || endPoint.node.textContent, + endOffset: endPoint.offset, + sameDOMsameNode: sameDOM && startPoint.node === start.node, + }), + stringify({ + startNode: start.node.outerHTML || start.node.textContent, + startOffset: start.offset, + endPoint: end.node.outerHTML || end.node.textContent, + endOffset: end.offset, + sameDOMsameNode: true, + }), + test.name + " (carret position)"); + } + } + }); + } + while (keyboardTests.length) { + defPollTest = defPollTest.then(pollTest.bind(null, keyboardTests.shift())); + } + + return defPollTest; +}; + + +/** + * Select a node in the dom with is offset. + * + * @param {String} startSelector + * @param {String} endSelector + * @param {jQuery} $editable + * @returns {Object} {sc, so, ec, eo} + */ +var select = (function () { + var __select = function (selector, $editable) { + var sel = selector.match(/^(.+?)(:contents\(\)\[([0-9]+)\]|:contents\(([0-9]+)\))?(->([0-9]+))?$/); + var $node = $editable.find(sel[1]); + return { + node: sel[2] ? $node.contents()[sel[3] ? +sel[3] : +sel[4]] : $node[0], + offset: sel[5] ? +sel[6] : 0, + }; + }; + return function (startSelector, endSelector, $editable) { + var start = __select(startSelector, $editable); + var end = endSelector ? __select(endSelector, $editable) : start; + return { + sc: start.node, + so: start.offset, + ec: end.node, + eo: end.offset, + }; + }; +})(); + +/** + * Trigger a keydown event. + * + * @param {String or Number} key (name or code) + * @param {jQuery} $editable + * @param {Object} [options] + * @param {Boolean} [options.firstDeselect] (default: false) true to deselect before pressing + */ +var keydown = function (key, $editable, options) { + var keyPress = {}; + if (typeof key === 'string') { + keyPress.key = key; + keyPress.keyCode = +_.findKey(keyboardMap, function (k) { + return k === key; + }); + } else { + keyPress.key = keyboardMap[key] || String.fromCharCode(key); + keyPress.keyCode = key; + } + var range = Wysiwyg.getRange($editable[0]); + if (!range) { + console.error("Editor have not any range"); + return; + } + if (options && options.firstDeselect) { + range.sc = range.ec; + range.so = range.eo; + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + } + var target = range.ec; + var $target = $(target.tagName ? target : target.parentNode); + var event = $.Event("keydown", keyPress); + $target.trigger(event); +}; + +return { + wysiwygData: wysiwygData, + createWysiwyg: createWysiwyg, + testKeyboard: testKeyboard, + select: select, + keydown: keydown, +}; + + +}); diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js deleted file mode 100644 index 1995add777d..00000000000 --- a/addons/web_editor/static/tests/web_editor_tests.js +++ /dev/null @@ -1,327 +0,0 @@ -odoo.define('web_editor.web_editor_tests', function (require) { -"use strict"; - -var FormView = require('web.FormView'); -var testUtils = require('web.test_utils'); -var core = require('web.core'); -var web_editor = require('web_editor.editor'); - -var _t = core._t; - -QUnit.module('web_editor', { - beforeEach: function() { - this.data = { - 'mass.mailing': { - fields: { - display_name: { string: "Displayed name", type: "char" }, - body: {string: "Message Body", type: "html"}, - }, - records: [{ - id: 1, - display_name: "first record", - body: "<div class='field_body'>yep</div>", - }], - onchanges: {}, - }, - }; - } -}); - -QUnit.test('field html widget', function (assert) { - var done = assert.async(); - assert.expect(3); - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<field name="body" widget="html" style="height: 100px"/>' + - '</form>', - res_id: 1, - }); - - assert.strictEqual(form.$('.field_body').text(), 'yep', - "should have rendered a div with correct content in readonly"); - assert.hasAttrValue(form.$('div[name=body]'), 'style', 'height: 100px', - "should have applied the style correctly"); - - testUtils.form.clickEdit(form); - - assert.strictEqual(form.$('.note-editable').html(), '<div class="field_body">yep</div>', - "should have rendered the field correctly in edit"); - - // summernote invokes handlers after a setTimeout, so we must wait as well - // before destroying the widget (otherwise we'll have a crash later on) - setTimeout(function () { - form.destroy(); - done(); - }, 0); -}); - -QUnit.test('field html widget (with options inline-style)', function (assert) { - var done = assert.async(); - assert.expect(3); - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<field name="body" widget="html" style="height: 100px" options="{\'style-inline\': true}"/>' + - '</form>', - res_id: 1, - }); - - assert.containsOnce(form, 'iframe', - "should have rendered an iframe without crashing in readonly"); - assert.hasAttrValue(form.$('div[name=body]'), 'style', 'height: 100px', - "should have applied the style correctly"); - - testUtils.form.clickEdit(form); - - assert.strictEqual(form.$('.note-editable').html(), '<div class="field_body">yep</div>', - "should have rendered the field correctly in edit"); - - // summernote invokes handlers after a setTimeout, so we must wait as well - // before destroying the widget (otherwise we'll have a crash later on) - setTimeout(function () { - form.destroy(); - done(); - }, 0); -}); - -QUnit.test('field html translatable', function (assert) { - assert.expect(3); - - var multiLang = _t.database.multi_lang; - _t.database.multi_lang = true; - - this.data['mass.mailing'].fields.body.translate = true; - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<field name="body" widget="html"/>' + - '</form>', - res_id: 1, - mockRPC: function (route, args) { - if (route === '/web/dataset/call_button' && args.method === 'translate_fields') { - assert.deepEqual(args.args, ['mass.mailing',1,'body',{}], "should call 'call_button' route"); - return $.when(); - } - return this._super.apply(this, arguments); - }, - }); - - assert.containsNone(form, '.oe_form_field_html_text .o_field_translate', - "should not have a translate button in readonly mode"); - - testUtils.form.clickEdit(form); - var $button = form.$('.oe_form_field_html_text .o_field_translate'); - assert.strictEqual($button.length, 1, "should have a translate button"); - testUtils.dom.click($button); - - form.destroy(); - _t.database.multi_lang = multiLang; -}); - -QUnit.test('field html_frame widget', function (assert) { - assert.expect(6); - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + - '</form>', - res_id: 1, - session: {user_context: {lang: "en_us"}}, - mockRPC: function (route) { - if (_.str.startsWith(route, '/logo')) { - // those tests will be executed twice, once in readonly and once in edit - assert.ok(route.search('model=mass.mailing') > 0, - "the route should specify the correct model"); - assert.ok(route.search('res_id=1') > 0, - "the route should specify the correct id"); - return $.when(); - } - return this._super.apply(this, arguments); - }, - }); - - assert.containsOnce(form, 'iframe', "should have rendered an iframe without crashing"); - - testUtils.form.clickEdit(form); - - assert.containsOnce(form, 'iframe', "should have rendered an iframe without crashing"); - - form.destroy(); -}); - -QUnit.test('field htmlsimple does not crash when commitChanges is called in mode=readonly', function (assert) { - assert.expect(1); - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<header>' + - '<button name="some_method" class="s" string="Do it" type="object"/>' + - '</header>' + - '<sheet>' + - '<field name="body"/>' + - '</sheet>' + - '</form>', - res_id: 1, - intercepts: { - execute_action: function () { - assert.step('execute_action'); - } - }, - }); - - testUtils.dom.click(form.$('button:contains(Do it)')); - form.destroy(); -}); - -QUnit.test('html_frame does not crash when saving in readonly', function (assert) { - // The 'Save' action may be triggered even in readonly (e.g. when clicking - // on a button in the form view) - assert.expect(2); - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<sheet>' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + - '</sheet>' + - '</form>', - res_id: 1, - mockRPC: function (route, args) { - if (args.method) { - assert.step(args.method); - } - if (_.str.startsWith(route, '/logo')) { - // manually call the callback to simulate that the iframe has - // been loaded (note: just the content, not the editor) - window.odoo[$.deparam(route).callback + '_content'].call(); - return $.when(); - } - return this._super.apply(this, arguments); - }, - }); - - form.saveRecord(); // before the fix done in this commit, it crashed here - - assert.verifySteps(['read']); - - form.destroy(); -}); - -QUnit.test('html_frame does not crash when saving in edit mode (editor not loaded)', function (assert) { - // The 'Save' action may be triggered when saving in edit mode very fast - // so that the editor may be not loaded, even though the content is! - assert.expect(2); - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<sheet>' + - '<field name="display_name"/>' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + - '</sheet>' + - '</form>', - res_id: 1, - mockRPC: function (route, args) { - if (args.method) { - assert.step(args.method); - } - if (_.str.startsWith(route, '/logo')) { - // manually call the callback to simulate that the iframe has - // been partially loaded (just the content, not the editor) - window.odoo[$.deparam(route).callback + '_content'](); - return $.when(); - } - return this._super.apply(this, arguments); - }, - }); - - testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input.o_input.o_field_char'), 'trululu'); - testUtils.form.clickSave(form); // crash without editor fully loaded - - assert.verifySteps(['read']); - - form.destroy(); -}); - -QUnit.test('html_frame saving in edit mode (editor and content fully loaded)', function (assert) { - var done = assert.async(); - assert.expect(4); - - var editorBar = new web_editor.Class(); - var loadDeferred = $.Deferred(); - var writeDeferred = $.Deferred(); - - var form = testUtils.createView({ - View: FormView, - model: 'mass.mailing', - data: this.data, - arch: '<form string="Partners">' + - '<sheet>' + - '<field name="display_name"/>' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + - '</sheet>' + - '</form>', - res_id: 1, - mockRPC: function (route, args) { - if (args.method) { - assert.step(args.method); - if (args.method === 'write') { - writeDeferred.resolve(); - } - } - if (_.str.startsWith(route, '/logo')) { - // manually call the callback to simulate that the iframe has - // been fully loaded (content + editor) - var callback = $.deparam(route).callback; - return loadDeferred.then(function () { - var contentCallback = window.odoo[callback + '_content']; - var editorCallback = window.odoo[callback + '_editor']; - if (editorCallback && contentCallback) { - contentCallback(); - editorCallback(editorBar); - } - }); - } - return this._super.apply(this, arguments); - }, - }); - - testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input.o_input.o_field_char'), 'trululu'); - testUtils.form.clickSave(form); - - loadDeferred.resolve(); // simulate late loading of html frame - - assert.strictEqual(form.$('.o_field_char').val(), 'trululu', - "should have saved the char field text"); - - writeDeferred.then( function () { // html_frame is async with write - assert.verifySteps(['read', 'write']); - form.destroy(); - done(); - }); - -}); - -}); diff --git a/addons/web_editor/static/tests/wysiwyg_keyboard_tests.js b/addons/web_editor/static/tests/wysiwyg_keyboard_tests.js new file mode 100644 index 00000000000..dfbddb2dc55 --- /dev/null +++ b/addons/web_editor/static/tests/wysiwyg_keyboard_tests.js @@ -0,0 +1,4179 @@ +odoo.define('web_editor.wysiwyg.keyboard_tests', function (require) { +"use strict"; + +var weTestUtils = require('web_editor.test_utils'); + +//-------------------------------------------------------------------------- +// tests +//-------------------------------------------------------------------------- + +QUnit.module('web_editor', {}, function () { +QUnit.module('wysiwyg', {}, function () { + +//-------------------------------------------------------------------------- +// Unbreakable tests +//-------------------------------------------------------------------------- +QUnit.module('Unbreakable'); + +var unbreakableTestDom = [ + ' <noteditable id="a">', + ' content_a_0', + ' <noteditable id="b">content_b_0</noteditable>', + ' <editable id="c">', + ' content_c_0', + ' <noteditable id="d">content_d_0</noteditable>', + ' <noteditable id="e">', + ' content_e_0', + ' <editable id="f">content_f_0</editable>', + ' content_e_1', + ' </noteditable>', + ' content_c_4', + ' </editable>', + ' <noteditable id="g">content_g_0</noteditable>', + ' <editable id="h">', + ' content_h_0', + ' <noteditable id="i">content_i_0</noteditable>', + ' content_h_2', + ' </editable>', + ' <editable id="j">', + ' content_j_0', + ' <editable id="k">content_k_0</editable>', + ' <editable id="l">content_l_0</editable>', + ' content_j_4', + ' </editable>', + ' </noteditable>', + ' <editable id="m">content_m_0</editable>', + ' <editable id="n">content_n_0</editable>', + '' +].join('\n'); +var UnbreakableTests = [{ + name: "nothing to do", + content: unbreakableTestDom, + steps: [{ + start: "#c:contents()[0]->13", + end: "#c:contents()[0]->15", + }], + test: { + content: unbreakableTestDom, + start: "#c:contents()[0]->13", + end: "#c:contents()[0]->15", + }, + }, + { + name: "nothing to do 2", + content: unbreakableTestDom, + steps: [{ + start: "#j:contents()[0]->13", + end: "#l:contents()[0]->5", + }], + test: { + content: unbreakableTestDom, + start: "#j:contents()[0]->13", + end: "#l:contents()[0]->5", + }, + }, + { + name: "nothing to do 3", + content: unbreakableTestDom, + steps: [{ + start: "#k:contents()[0]->3", + end: "#l:contents()[0]->5", + }], + test: { + content: unbreakableTestDom, + start: "#k:contents()[0]->3", + end: "#l:contents()[0]->5", + }, + }, + { + name: "find the first allowed node", + content: unbreakableTestDom, + steps: [{ + start: "#a:contents()[0]->13", + }], + test: { + content: unbreakableTestDom, + start: "#c:contents()[0]->0", + end: "#c:contents()[0]->0", + }, + }, + { + name: "find the first allowed node and collapse the selection", + content: unbreakableTestDom, + steps: [{ + start: "#a:contents()[0]->10", + end: "#b:contents()[0]->3", + }], + test: { + content: unbreakableTestDom, + start: "#c:contents()[0]->0", + end: "#c:contents()[0]->0", + }, + }, + { + name: "resize the range to the allowed end", + content: unbreakableTestDom, + steps: [{ + start: "#a:contents()[0]->10", + end: "#c:contents()[0]->14", + }], + test: { + content: unbreakableTestDom, + start: "#c:contents()[0]->0", + end: "#c:contents()[0]->14", + }, + }, + { + name: "resize the range to the allowed start", + content: unbreakableTestDom, + steps: [{ + start: "#c:contents()[0]->15", + end: "#d:contents()[0]->4", + }], + test: { + content: unbreakableTestDom, + start: "#c:contents()[0]->15", + end: "#c:contents()[0]->37", + }, + }, + { + name: "resize the range to the allowed node that contains unbreakable node", + content: unbreakableTestDom, + steps: [{ + start: "#g:contents()[0]->5", + end: "#h:contents()[2]->15", + }], + test: { + content: unbreakableTestDom, + start: "#h:contents()[0]->0", + end: "#h:contents()[2]->15", + }, + }, + { + name: "resize the range to the allowed node between the start and the end", + content: unbreakableTestDom, + steps: [{ + start: "#e:contents()[0]->15", + end: "#c:contents()[4]->15", + }], + test: { + content: unbreakableTestDom, + start: "#f:contents()[0]->0", + end: "#f:contents()[0]->11", + }, + }, + { + name: "resize the range to the allowed start with the entirety of the unbreakable node", + content: unbreakableTestDom, + steps: [{ + start: "#c:contents()[0]->15", + end: "#e:contents()[0]->15", + }], + test: { + content: unbreakableTestDom, + start: "#c:contents()[0]->15", + end: "#d->1", + }, + }, + { + name: "resize the range to the allowed start and delete content", + content: unbreakableTestDom, + steps: [{ + start: "#c:contents()[0]->15", + end: "#e:contents()[0]->15", + key: "DELETE", + }], + test: { + content: unbreakableTestDom + // when we remove, we clean the invisible space before and after the deleted area + .replace(/>[\s]+content_c_0[\s\S]+content_d_0<\/noteditable>\s+/, '>co') + .replace(/^\s+/, ''), // corner effect of the clean and normalize + start: "#c:contents()[0]->2", + end: "#c:contents()[0]->2", + }, + }, + { + name: "delete unbreakable nodes in a breakable", + content: unbreakableTestDom, + steps: [{ + start: "#c:contents()[0]->15", + end: "#c:contents()[4]->19", + key: "DELETE", + }], + test: { + content: unbreakableTestDom + // when we remove, we clean the invisible space before and after the deleted area + .replace(/>[\s]+content_c_0[\s\S]*content_c_4\s*/, '>cot_c_4') + .replace(/^\s+/, ''), // corner effect of the clean and normalize + start: "#c:contents()[0]->2", + end: "#c:contents()[0]->2", + }, + }, + { + name: "select range with 2 nodes on the root", + content: unbreakableTestDom, + steps: [{ + start: "#m:contents()[0]->5", + end: "#n:contents()[0]->5", + }], + test: { + content: unbreakableTestDom, + start: "#m:contents()[0]->5", + end: "#n:contents()[0]->5", + }, + }, + { + name: "ENTER in an unbreakable p", + content: "<p class='unbreakable'>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + end: "p:contents()[0]->1", + key: 'ENTER', + }], + test: { + content: '<p class="unbreakable">d<br>om to edit</p>', + start: "p:contents()[2]->0", + }, + }, +]; + +QUnit.test('Unbreakable selection and edition', function (assert) { + var done = assert.async(); + weTestUtils.createWysiwyg({ + data: this.data, + useOnlyTestUnbreakable: true, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + weTestUtils.testKeyboard($editable, assert, UnbreakableTests).then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +//-------------------------------------------------------------------------- +// Keyboard integration +//-------------------------------------------------------------------------- + +QUnit.module('Keyboard'); + +var keyboardTestsChar = [{ + name: "visible char in a p tag", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->3", + keyCode: 66, + }], + test: { + content: "<p>domB to edit</p>", + start: "p:contents()[0]->4", + end: null, + check: function ($editable, assert) { + assert.ok(true); + }, + }, + }, + { + name: "visible char in a link tag entirely selected", + content: '<div><a href="#">dom to edit</a></div>', + steps: [{ + start: "a:contents()[0]->0", + end: "a:contents()[0]->11", + key: 'a', + }], + test: { + content: '<div><a href="#">​a​</a></div>', + start: "a:contents()[0]->2", + }, + }, + { + name: "'a' on a selection of all the contents of a complex dom", + content: "<p><b>dom</b></p><p><b>to<br>completely</b>remov<i>e</i></p>", + steps: [{ + start: "p:contents()[0]->0", + end: "i:contents()[0]->1", + key: 'a', + }], + test: { + content: "<p><b>a</b></p>", + start: "b:contents()[0]->1", + }, + }, + { + name: "'a' on a selection of all the contents of a complex dom (2)", + content: "<h1 class=\"a\"><font style=\"font-size: 62px;\"><b>dom to</b>edit</font></h1>", + steps: [{ + start: "font:contents()[0]->0", + end: "font:contents()[1]->4", + key: 'a', + }], + test: { + content: "<h1 class=\"a\"><font style=\"font-size: 62px;\"><b>a</b></font></h1>", + start: "b:contents()[0]->1", + }, + }, + { + name: "'a' before an image", + content: '<p>xxx <img src="/web_editor/static/src/img/transparent.png"> yyy</p>', + steps: [{ + start: "p:contents()[0]->4", + key: 'a', + }], + test: { + content: '<p>xxx a<img data-src="/web_editor/static/src/img/transparent.png"> yyy</p>', + start: "p:contents()[0]->5", + }, + }, + { + name: "'a' before an image (2)", + content: '<p>xxx <img src="/web_editor/static/src/img/transparent.png"> yyy</p>', + steps: [{ + start: "img->0", + key: 'a', + }], + test: { + content: '<p>xxx a<img data-src="/web_editor/static/src/img/transparent.png"> yyy</p>', + start: "p:contents()[0]->5", + }, + }, + { + name: "'a' before an image in table", + content: '<table><tbody><tr><td><p>xxx</p></td><td><p><img src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + steps: [{ + start: "img->0", + key: 'a', + }], + test: { + content: '<table><tbody><tr><td><p>xxx</p></td><td><p>a<img data-src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "'a' on invisible text before an image in table", + content: '<table><tbody><tr><td><p>xxx</p></td><td><p><img src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + steps: [{ + start: "p:eq(1)->0", + key: 'a', + }], + test: { + content: '<table><tbody><tr><td><p>xxx</p></td><td><p>a<img data-src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "'a' before an image in table without spaces", + content: '<table><tbody><tr><td><p>xxx</p></td><td><p><img src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + steps: [{ + start: "img->0", + key: 'a', + }], + test: { + content: '<table><tbody><tr><td><p>xxx</p></td><td><p>a<img data-src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "'a' before an image in table without spaces (2)", + content: '<table><tbody><tr><td><p>xxx</p></td><td><p><img src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + steps: [{ + start: "td:eq(1)->0", + key: 'a', + }], + test: { + content: '<table><tbody><tr><td><p>xxx</p></td><td><p>a<img data-src="/web_editor/static/src/img/transparent.png"></p></td><td><p>yyy</p></td></tr></tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "'a' before an image in table with spaces", + content: + '<table><tbody>\n' + + ' <tr>\n' + + ' <td>\n' + + ' <p>xxx</p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p><img src="/web_editor/static/src/img/transparent.png"></p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>yyy</p>\n' + + ' </td>\n' + + ' </tr>\n' + + '</tbody></table>', + steps: [{ + start: "img->0", + key: 'a', + }], + test: { + content: + '<table><tbody>\n' + + ' <tr>\n' + + ' <td>\n' + + ' <p>xxx</p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>a<img data-src="/web_editor/static/src/img/transparent.png"></p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>yyy</p>\n' + + ' </td>\n' + + ' </tr>\n' + + '</tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "'a' before an image in table with spaces (2)", + content: + '<table><tbody>\n' + + ' <tr>\n' + + ' <td>\n' + + ' <p>xxx</p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p><img src="/web_editor/static/src/img/transparent.png"></p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>yyy</p>\n' + + ' </td>\n' + + ' </tr>\n' + + '</tbody></table>', + steps: [{ + start: "td:eq(1)->1", + key: 'a', + }], + test: { + content: + '<table><tbody>\n' + + ' <tr>\n' + + ' <td>\n' + + ' <p>xxx</p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>a<img data-src="/web_editor/static/src/img/transparent.png"></p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>yyy</p>\n' + + ' </td>\n' + + ' </tr>\n' + + '</tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "'a' before an image in table with spaces (3)", + content: + '<table><tbody>\n' + + ' <tr>\n' + + ' <td>\n' + + ' <p>xxx</p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p><img src="/web_editor/static/src/img/transparent.png"></p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>yyy</p>\n' + + ' </td>\n' + + ' </tr>\n' + + '</tbody></table>', + steps: [{ + start: "td:eq(1):contents()[0]->12", + key: 'a', + }], + test: { + content: + '<table><tbody>\n' + + ' <tr>\n' + + ' <td>\n' + + ' <p>xxx</p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>a<img data-src="/web_editor/static/src/img/transparent.png"></p>\n' + + ' </td>\n' + + ' <td>\n' + + ' <p>yyy</p>\n' + + ' </td>\n' + + ' </tr>\n' + + '</tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "'a' on begin of a span with fake_editable", + content: + '<div class="o_fake_not_editable" contentEditable="false">\n' + + ' <div>\n' + + ' <label>\n' + + ' <input type="checkbox"/>\n' + + ' <span class="o_fake_editable" contentEditable="true">\n' + + ' dom to edit\n' + + ' </span>\n' + + ' </label>\n' + + ' </div>\n' + + '</div>', + steps: [{ + start: "span:contents(0)->10", + key: 'a', + }], + test: { + content: + '<div>\n' + + ' <div>\n' + + ' <label>\n' + + ' <input type="checkbox">\n' + + ' <span>\n' + + ' adom to edit\n' + + ' </span>\n' + + ' </label>\n' + + ' </div>\n' + + '</div>', + start: "span:contents(0)->11", + }, + }, + { + name: "'a' on all contents of p starting with an icon", + content: '<p><span class="fa fa-star"></span>bbb</p>', + steps: [{ + start: "span->0", + end: 'p:contents(1)->3', + key: 'a', + }], + test: { + content: '<p>a</p>', + start: "p:contents()[0]->1", + }, + }, + { + name: "' ' at start of p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->0", + key: ' ', + }], + test: { + content: '<p> dom to edit</p>', + start: "p:contents()[0]->1", + }, + }, + { + name: "' ' at end of p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->11", + key: ' ', + }], + test: { + content: '<p>dom to edit </p>', + start: "p:contents()[0]->12", + }, + }, + { + name: "' ' within p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->2", + key: ' ', + }], + test: { + content: '<p>do m to edit</p>', + start: "p:contents()[0]->3", + }, + }, + { + name: "' ' before space within p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->3", + key: ' ', + }], + test: { + content: '<p>dom to edit</p>', + start: "p:contents()[0]->4", + }, + }, + { + name: "' ' after space within p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->4", + key: ' ', + }], + test: { + content: '<p>dom to edit</p>', + start: "p:contents()[0]->5", + }, + }, + { + name: "3x ' ' at start of p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->0", + key: ' ', + }, { + key: ' ', + }, { + key: ' ', + }], + test: { + content: '<p> dom to edit</p>', + start: "p:contents()[0]->3", + }, + }, + { + name: "3x ' ' at end of p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->11", + key: ' ', + }, { + key: ' ', + }, { + key: ' ', + }], + test: { + content: '<p>dom to edit </p>', + start: "p:contents()[0]->14", + }, + }, + { + name: "3x ' ' within p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->2", + key: ' ', + }, { + key: ' ', + }, { + key: ' ', + }], + test: { + content: '<p>do m to edit</p>', + start: "p:contents()[0]->5", + }, + }, + { + name: "3x ' ' before space in p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->3", + key: ' ', + }, { + key: ' ', + }, { + key: ' ', + }], + test: { + content: '<p>dom to edit</p>', + start: "p:contents()[0]->6", + }, + }, + { + name: "3x ' ' after space in p", + content: '<p>dom to edit</p>', + steps: [{ + start: "p:contents()[0]->4", + key: ' ', + }, { + key: ' ', + }, { + key: ' ', + }], + test: { + content: '<p>dom to edit</p>', + start: "p:contents()[0]->7", + }, + }, + { + name: "'a' in unbreakable with font", + content: '<div class="unbreakable">dom <span class="fa fa-heart"></span>to edit</div>', + steps: [{ + start: "div:contents(2)->2", + key: 'a', + }], + test: { + content: '<div class="unbreakable">dom <span class="fa fa-heart"></span>toa edit</div>', + start: "div:contents(2)->3", + }, + }, + { + name: "'a' on begin of unbreakable inline node", + content: 'dom <strong class="unbreakable">to</strong> edit', + steps: [{ + start: "strong:contents(0)->0", + key: 'a', + }], + test: { + content: 'dom <strong class="unbreakable">ato</strong> edit', + start: "strong:contents(0)->1", + }, + }, + { + name: "'a' on end of unbreakable inline node", + content: '<div>dom <strong class="unbreakable">to</strong> edit</div>', + steps: [{ + start: "strong:contents(0)->2", + key: 'a', + }], + test: { + content: '<div>dom <strong class="unbreakable">toa</strong> edit</div>', + start: "strong:contents(0)->3", + }, + }, + { + name: "'1' on begin of value of a field currency", + content: + '<noteditable>\n' + + '<b data-oe-type="monetary" class="oe_price editable">$ <span class="oe_currency_value">750.00</span></b>\n' + + '</noteditable>', + steps: [{ + start: "span:contents(0)->0", + key: '1', + }], + test: { + content: + '<noteditable>\n' + + '<b data-oe-type="monetary" class="oe_price editable">$ <span class="oe_currency_value">1750.00</span></b>\n' + + '</noteditable>', + start: "span:contents(0)->1", + }, + }, + { + name: "'1' on end of value of a field currency", + content: + '<noteditable>\n' + + '<b data-oe-type="monetary" class="oe_price editable">$ <span class="oe_currency_value">750.00</span></b>\n' + + '</noteditable>', + steps: [{ + start: "span:contents(0)->6", + key: '1', + }], + test: { + content: + '<noteditable>\n' + + '<b data-oe-type="monetary" class="oe_price editable">$ <span class="oe_currency_value">750.001</span></b>\n' + + '</noteditable>', + start: "span:contents(0)->7", + }, + }, + { + name: "'1' on begin of editable in noteditable", + content: + '<noteditable contenteditable="false">\n' + + '<b data-oe-type="monetary" class="oe_price editable" contenteditable="true">$ <span class="oe_currency_value">750.00</span></b>\n' + + '</noteditable>', + steps: [{ + start: "span:contents(0)->0", + key: '1', + }], + test: { + content: + '<noteditable>\n' + + '<b data-oe-type="monetary" class="oe_price editable">$ <span class="oe_currency_value">1750.00</span></b>\n' + + '</noteditable>', + start: "span:contents(0)->1", + }, + }, + { + name: "'1' on end of editable in noteditable", + content: + '<noteditable contenteditable="false">\n' + + '<b data-oe-type="monetary" class="oe_price editable" contenteditable="true">$ <span class="oe_currency_value">750.00</span></b>\n' + + '</noteditable>', + steps: [{ + start: "span:contents(0)->6", + key: '1', + }], + test: { + content: + '<noteditable>\n' + + '<b data-oe-type="monetary" class="oe_price editable">$ <span class="oe_currency_value">750.001</span></b>\n' + + '</noteditable>', + start: "span:contents(0)->7", + }, + }, + { + name: "'a' on editable with before&after in noteditable", + content: + '<style>#test-before-after:before { content: "placeholder";} #test-before-after:after { content: "\\00a0";}</style>\n' + + '<noteditable contenteditable="false">\n' + + '<b id="test-before-after" class="editable" contenteditable="true"></b>\n' + + '</noteditable>', + steps: [{ + start: "b->0", + key: 'a', + }], + test: { + content: + '<style>#test-before-after:before { content: "placeholder";} #test-before-after:after { content: "\\00a0";}</style>\n' + + '<noteditable>\n' + + '<b id="test-before-after" class="editable">a</b>\n' + + '</noteditable>', + start: "b:contents(0)->1", + }, + }, +]; + +QUnit.test('Char', function (assert) { + var done = assert.async(); + weTestUtils.createWysiwyg({ + data: this.data, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + weTestUtils.testKeyboard($editable, assert, keyboardTestsChar).then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +var keyboardTestsEnter = [{ + name: "in p: ENTER", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + key: 'ENTER', + }], + test: { + content: "<p>d</p><p>om to edit</p>", + start: "p:contents()[1]->0", + }, + }, + { + name: "in p: 2x ENTER", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + key: 'ENTER', + }, { + key: 'ENTER', + }], + test: { + content: "<p>d</p><p><br></p><p>om to edit</p>", + start: "p:contents()[2]->0", + }, + }, + { + name: "in p: SHIFT+ENTER", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p>d<br>om to edit</p>", + start: "p:contents()[2]->0", + }, + }, + { + name: "in empty-p: SHIFT+ENTER", + content: "<p><br></p>", + steps: [{ + start: "p->1", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p><br><br></p>", + start: "br:eq(1)->0", + }, + }, + { + name: "in p: 2x SHIFT+ENTER", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p>d<br><br>om to edit</p>", + start: "p:contents()[3]->0", + }, + }, + { + name: "in empty-p: 2x SHIFT+ENTER", + content: "<p><br></p>", + steps: [{ + start: "p->1", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p><br><br><br></p>", + start: "br:eq(2)->0", + }, + }, + { + name: "in p: ENTER -> SHIFT+ENTER", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + key: 'ENTER', + }, { + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p>d</p><p><br>om to edit</p>", + start: "p:eq(1):contents()[1]->0", + }, + }, + { + name: "in p: ENTER on selection", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + end: "p:contents()[0]->7", + key: 'ENTER', + }], + test: { + content: "<p>d</p><p>edit</p>", + start: "p:contents()[1]->0", + end: "p:contents()[1]->0", + }, + }, + { + name: "in p: SHIFT+ENTER on selection", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + end: "p:contents()[0]->7", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p>d<br>edit</p>", + start: "p:contents()[2]->0", + }, + }, + + // list UL / OL + + { + name: "in li: SHIFT+ENTER at start", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<ul><li><br>dom to edit</li></ul>", + start: "li:contents()[1]->0", + }, + }, + { + name: "in li: SHIFT+ENTER within contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->5", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<ul><li>dom t<br>o edit</li></ul>", + start: "li:contents()[2]->0", + }, + }, + { + name: "in empty-li: SHIFT+ENTER", + content: "<ul><li><br></li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<ul><li><br><br></li></ul>", + start: "br:eq(1)->0", + }, + }, + { + name: "in li: SHIFT+ENTER on selection", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->1", + end: "li:contents()[0]->7", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<ul><li>d<br>edit</li></ul>", + start: "li:contents()[2]->0", + }, + }, + { + name: "in li: ENTER at start", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'ENTER', + }], + test: { + content: "<ul><li><br></li><li>dom to edit</li></ul>", + start: "li:contents()[1]->0", + }, + }, + { + name: "in li: ENTER within contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->5", + key: 'ENTER', + }], + test: { + content: "<ul><li>dom t</li><li>o edit</li></ul>", + start: "li:eq(1):contents()[0]->0", + }, + }, + { + name: "in li: ENTER on selection", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->1", + end: "li:contents()[0]->7", + key: 'ENTER', + }], + test: { + content: "<ul><li>d</li><li>edit</li></ul>", + start: "li:eq(1):contents()[0]->0", + }, + }, + { + name: "in li: ENTER on selection of all contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + end: "li:contents()[0]->11", + key: 'ENTER', + }], + test: { + content: "<p><br></p>", + start: "br->0", + }, + }, + { + name: "in li: ENTER -> 'a' on selection of all contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + end: "li:contents()[0]->11", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p>a</p>", + start: "p:contents()[0]->1", + }, + }, + { + name: "across 2 li: ENTER on partial selection", + content: "<ul><li>dom to edit</li><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->1", + end: "li:eq(1):contents()[0]->4", + key: 'ENTER', + }], + test: { + content: "<ul><li>d</li><li>to edit</li></ul>", + start: "li:eq(1):contents()[0]->0", // we are after the <br>, the carret is on the li with an offset equal to the node length + }, + }, + { + name: "in li: ENTER at end", + content: "<ul><li><p>dom to edit</p></li></ul>", + steps: [{ + start: "p:contents()[0]->11", + key: 'ENTER', + }], + test: { + content: "<ul><li><p>dom to edit</p></li><li><p><br></p></li></ul>", + start: "br->0", + }, + }, + { + name: "in li: 2x ENTER at end", + content: "<ul><li><p>dom to edit</p></li></ul>", + steps: [{ + start: "p:contents()[0]->11", + key: 'ENTER', + }, { + key: 'ENTER', + }], + test: { + content: "<ul><li><p>dom to edit</p></li></ul><p><br></p>", + start: "br->0", + }, + }, + { + name: "in ul.list-group > li: ENTER at end", + content: '<ul class="list-group"><li><p>dom to edit</p></li></ul>', + steps: [{ + start: "p:contents()[0]->11", + key: 'ENTER', + }, { + key: 'ENTER', + }], + test: { + content: '<ul class="list-group"><li><p>dom to edit</p></li><li><p><br></p></li><li><p><br></p></li></ul>', + start: "p:eq(2) br->0", + }, + }, + { + name: "in indented-li: 2x ENTER at end", + content: "<ul><li><p>aaa</p></li><ul><li><p>dom to edit</p></li></ul><li><p>bbb</p></li></ul>", + steps: [{ + start: "ul ul p:contents()[0]->11", + key: 'ENTER', + }, { + key: 'ENTER', + }], + test: { + content: "<ul><li><p>aaa</p></li><ul><li><p>dom to edit</p></li></ul><li><p><br></p></li><li><p>bbb</p></li></ul>", + start: "p:eq(2) br->0", + }, + }, + { + name: "in indented-li with font: 2x ENTER at end", + content: "<ul><li><p>aaa</p></li><ul><li><p><font style=\"color\">dom to edit</font></p></li></ul><li><p>bbb</p></li></ul>", + steps: [{ + start: "font:contents()[0]->11", + key: 'ENTER', + }, { + key: 'ENTER', + }], + test: { + content: "<ul><li><p>aaa</p></li><ul><li><p><font style=\"color\">dom to edit</font></p></li></ul><li><p><font style=\"color\"><br></font></p></li><li><p>bbb</p></li></ul>", + start: "p:eq(2) br->0", + }, + }, + { + name: "in li > empty-p: ENTER", + content: "<ul><li><p><br></p></li></ul>", + steps: [{ + start: "p:contents()[0]->0", + key: 'ENTER', + }], + test: { + content: "<p><br></p>", + start: "br->0", + }, + }, + { + name: "in li > p > empty-b: ENTER", + content: "<ul><li><p><b><br></b></p></li></ul>", + steps: [{ + start: "b:contents()[0]->0", + key: 'ENTER', + }], + test: { + content: "<p><b><br></b></p>", + start: "br->0", + }, + }, + + // end list UL / OL + + { + name: "after p > b: SHIFT+ENTER", + content: "<p><b>dom</b> to edit</p>", + steps: [{ + start: "p:contents()[1]->0", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p><b>dom</b><br> to edit</p>", + start: "p:contents()[2]->0", + }, + }, + { + name: "in p > b: ENTER -> 'a'", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->2", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p><b>do</b></p><p><b>am to edit</b></p>", + start: "b:eq(1):contents()[0]->1", + }, + }, + { + name: "after p > b: ENTER -> 'a'", + content: "<p><b>dom</b> to edit</p>", + steps: [{ + start: "p:contents()[1]->0", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p><b>dom</b></p><p>a to edit</p>", + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "after p > b: SHIFT+ENTER -> 'a'", + content: "<p><b>dom</b> to edit</p>", + steps: [{ + start: "p:contents()[1]->0", + key: 'ENTER', + shiftKey: true, + }, { + key: 'a', + }], + test: { + content: "<p><b>dom</b><br>a to edit</p>", + start: "p:contents()[2]->1", + }, + }, + { + name: "in p (other-p > span.a before - p > span.b after): ENTER at beginning", + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\">edit</span></p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'ENTER', + }], + test: { + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\"><br></span></p><p><span class=\"b\">edit</span></p>", + start: "span:eq(2):contents()[0]->0", + }, + }, + { + name: "in p (other-p > span.a before - p > span.b after): ENTER -> 'a' at beginning", + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\">edit</span></p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\"><br></span></p><p><span class=\"b\">aedit</span></p>", + start: "span:eq(2):contents()[0]->1", + }, + }, + { + name: "in p (other-p > span.a before - p > span.b after): SHIFT+ENTER at beginning", + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\">edit</span></p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\"><br>edit</span></p>", + start: "span:eq(1):contents()[1]->0", + }, + }, + { + name: "in p (other-p > span.a before - p > span.b after): SHIFT+ENTER -> 'a' at beginning", + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\">edit</span></p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'ENTER', + shiftKey: true, + }, { + key: 'a', + }], + test: { + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\"><br>aedit</span></p>", + start: "span:eq(1):contents()[1]->1", + }, + }, + { + name: "in p: ENTER on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'ENTER', + }], + test: { + content: "<p><br></p><p><br></p>", + start: "p:eq(1) br->0", + }, + }, + { + name: "in p: ENTER -> 'a' on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p><br></p><p>a</p>", + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "in p: SHIFT+ENTER on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p><br><br></p>", + start: "br:eq(1)->0", + }, + }, + { + name: "in p: SHIFT+ENTER -> 'a' on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'ENTER', + shiftKey: true, + }, { + key: 'a', + }], + test: { + content: "<p><br>a</p>", + start: "p:contents()[1]->1", + }, + }, + { + name: "in p: 2x ENTER -> 'a' on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->3", + key: 'ENTER', + }, { + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p>dom</p><p><br></p><p>a to edit</p>", + start: "p:eq(2):contents()[0]->1", + }, + }, + { + name: "in p > b: ENTER at start", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->0", + key: 'ENTER', + }], + test: { + content: "<p><b><br></b></p><p><b>dom to edit</b></p>", + start: "b:eq(1):contents()[0]->0", + }, + }, + { + name: "in p > b: ENTER", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + }], + test: { + content: "<p><b>dom</b></p><p><b> to edit</b></p>", + start: "b:eq(1):contents()[0]->0", + }, + }, + { + name: "in p > b: SHIFT+ENTER -> ENTER", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + }], + test: { + content: "<p><b>dom<br>​</b></p><p><b> to edit</b></p>", + start: "b:eq(1):contents()[0]->0", + }, + }, + { + name: "in p > b: ENTER -> a'", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p><b>dom</b></p><p><b>a to edit</b></p>", + start: "b:eq(1):contents()[0]->1", + }, + }, + { + name: "in p > b: SHIFT+ENTER", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p><b>dom<br> to edit</b></p>", + start: "b:contents()[2]->0", + }, + }, + { + name: "in p > b: SHIFT+ENTER -> 'a'", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'a', + }], + test: { + content: "<p><b>dom<br>a to edit</b></p>", + start: "b:contents()[2]->1", + }, + }, + { + name: "in p > b: SHIFT+ENTER -> ENTER -> 'a'", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p><b>dom<br>​</b></p><p><b>a to edit</b></p>", + start: "b:eq(1):contents()[0]->1", + }, + }, + { + name: "in span > b: ENTER", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + }], + test: { + content: "<span><b>dom</b></span><br><span><b>​ to edit</b></span>", + start: "b:eq(1):contents()[0]->1", + }, + }, + { + name: "in span > b: SHIFT+ENTER -> ENTER", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + }], + test: { + content: "<span><b>dom<br></b></span><br><span><b>​ to edit</b></span>", + start: "b:eq(1):contents()[0]->1", + }, + }, + { + name: "in span > b: ENTER -> a'", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<span><b>dom</b></span><br><span><b>a to edit</b></span>", + start: "b:eq(1):contents()[0]->1", + }, + }, + { + name: "in span > b: SHIFT+ENTER", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<span><b>dom<br> to edit</b></span>", + start: "b:contents()[2]->0", + }, + }, + { + name: "in span > b: SHIFT+ENTER -> 'a'", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'a', + }], + test: { + content: "<span><b>dom<br>a to edit</b></span>", + start: "b:contents()[2]->1", + }, + }, + { + name: "in span > b: SHIFT+ENTER -> ENTER -> 'a'", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<span><b>dom<br></b></span><br><span><b>a to edit</b></span>", + start: "b:eq(1):contents()[0]->1", + }, + }, + { + name: "in p: 2x SHIFT+ENTER -> 'a'", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + shiftKey: true, + }, { + key: 'a', + }], + test: { + content: "<p>dom<br><br>a to edit</p>", + start: "p:contents()[3]->1", + }, + }, + { + name: "in p: ENTER -> SHIFT+ENTER -> 'a'", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->3", + key: 'ENTER', + }, { + key: 'ENTER', + shiftKey: true, + }, { + key: 'a', + }], + test: { + content: "<p>dom</p><p><br>a to edit</p>", + start: "p:eq(1):contents()[1]->1", + }, + }, + { + name: "in empty-p (p before and after): ENTER -> 'a'", + content: "<p>dom </p><p><br></p><p>to edit</p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<p>dom </p><p><br></p><p>a</p><p>to edit</p>", + start: "p:eq(2):contents()[0]->1", + }, + }, + { + name: "in p: SHIFT+ENTER at end", + content: "<p>dom </p><p>to edit</p>", + steps: [{ + start: "p:first:contents()[0]->4", + key: 'ENTER', + shiftKey: true, + }], + test: { + content: "<p>dom <br>​</p><p>to edit</p>", + start: "p:first:contents()[2]->0", + }, + }, + { + name: "in p: SHIFT+ENTER at end -> '寺'", + content: "<p>dom </p><p>to edit</p>", + steps: [{ + start: "p:first:contents()[0]->4", + key: 'ENTER', + shiftKey: true, + }, { + keyCode: 23546, /*temple in chinese*/ + }], + test: { + content: "<p>dom <br>寺</p><p>to edit</p>", + start: "p:first:contents()[2]->1", + }, + }, + { + name: "in empty-p (div > a after): 3x SHIFT+ENTER -> 'a'", + content: "<p><br></p><div><a href='#'>dom to edit</a></div>", + steps: [{ + start: "p->1", + key: 'ENTER', + shiftKey: true, + }, + { + key: 'ENTER', + shiftKey: true, + }, + { + key: 'ENTER', + shiftKey: true, + }, + { + key: 'a', + } + ], + test: { + content: "<p><br><br><br>a</p><div><a href=\"#\">dom to edit</a></div>", + start: "p:contents()[3]->1", + }, + }, + + // Buttons + + { + name: "in div > a.btn: ENTER -> 'a' at start (before invisible space)", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​dom to edit​</a></div>", + steps: [{ + start: "a:contents()[0]->0", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​a​</a><a class=\"btn\" href=\"#\">dom to edit</a></div>", + // split button has no text so the placeholder text is selected then replaced by 'a' + start: "a:contents()[0]->2", + }, + }, + { + name: "in div > a.btn: ENTER -> 'a' at start (after invisible space)", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​dom to edit​</a></div>", + steps: [{ + start: "a:contents()[0]->1", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​a​</a><a class=\"btn\" href=\"#\">dom to edit</a></div>", + // split button has no text so the placeholder text is selected then replaced by 'a' + start: "a:contents()[0]->2", + }, + }, + { + name: "in div > a.btn: ENTER -> 'a' within contents", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​dom to edit​</a></div>", + steps: [{ + start: "a:contents()[0]->6", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">dom t</a><a class=\"btn\" href=\"#\">​ao edit​</a></div>", + start: "a:eq(1):contents()[0]->2", + }, + }, + { + name: "in div > a.btn: ENTER -> 'a' at end (before invisible space)", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​dom to edit​</a></div>", + steps: [{ + start: "a:contents()[0]->12", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">dom to edit</a><a class=\"btn\" href=\"#\">​a​</a></div>", + start: "a:eq(1):contents()[0]->2", + }, + }, + { + name: "in div > a.btn: ENTER -> 'a' at end (after invisible space)", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​dom to edit​</a></div>", + steps: [{ + start: "a:contents()[0]->13", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">dom to edit</a><a class=\"btn\" href=\"#\">​a​</a></div>", + start: "a:eq(1):contents()[0]->2", + }, + }, + { + name: "in div > button.btn: ENTER -> 'a' at end (after invisible space)", + content: "<div class=\"unbreakable\"><button class=\"btn\" href=\"#\">​dom to edit​</button></div>", + steps: [{ + start: "button:contents()[0]->13", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><button class=\"btn\" href=\"#\">dom to edit</button><button class=\"btn\" href=\"#\">​a​</button></div>", + start: "button:eq(1):contents()[0]->2", + }, + }, + { + name: "in div > a.btn: ENTER -> 'a' on partial selection", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​dom to edit​</a></div>", + steps: [{ + start: "a:contents()[0]->4", + end: "a:contents()[0]->8", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">dom</a><a class=\"btn\" href=\"#\">​aedit​</a></div>", + start: "a:eq(1):contents()[0]->2", + }, + }, + { + name: "in div > a.btn: ENTER -> 'a' on selection of all visible text", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">dom to edit</a></div>", + steps: [{ + start: "a:contents()[0]->0", + end: "a:contents()[0]->11", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​a​</a><a class=\"btn\" href=\"#\">Label</a></div>", + // Removing all text in a link replaces that text with "Label" + start: "a:contents()[0]->2", + }, + }, + { + name: "across 2 a.btn: ENTER on selection across two a.btn", + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">dom not to edit</a><a class=\"btn\" href=\"#\">other dom not to edit</a></div>", + steps: [{ + start: "a:contents()[0]->0", + end: "a:eq(1):contents()[0]->11", + key: 'ENTER', + }], + test: { + content: "<div class=\"unbreakable\"><a class=\"btn\" href=\"#\">​Label​</a><a class=\"btn\" href=\"#\">ot to edit</a></div>", + start: "a:contents()[0]->1", + end: "a:contents()[0]->6", + }, + }, +]; + +QUnit.test('Enter', function (assert) { + var done = assert.async(); + weTestUtils.createWysiwyg({ + data: this.data, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + weTestUtils.testKeyboard($editable, assert, keyboardTestsEnter).then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +var keyboardTestsComplex = [{ + name: "in span > b: SHIFT+ENTER -> BACKSPACE -> 'a'", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<span><b>doma to edit</b></span>", + start: "b:contents()[0]->4", + }, + }, + { + name: "in span > b: SHIFT+ENTER -> ENTER -> BACKSPACE", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<span><b>dom<br> to edit</b></span>", + start: "b:contents(2)->0", + }, + }, + { + name: "in span > b: SHIFT+ENTER -> ENTER -> BACKSPACE -> 'a'", + content: "<span><b>dom to edit</b></span>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + }, { + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<span><b>dom<br>a to edit</b></span>", + start: "b:contents()[2]->1", + }, + }, + { + name: "in p > b: 2x SHIFT+ENTER -> BACKSPACE", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + shiftKey: true, + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p><b>dom<br> to edit</b></p>", + start: "b:contents()[2]->0", + }, + }, + { + name: "in p > b: 2x ENTER -> 2x BACKSPACE", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: false + }, { + key: 'ENTER', + shiftKey: false + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p><b>dom to edit</b></p>", + start: "b:contents()[0]->3", + }, + }, + { + name: "in empty-p: 2x ENTER -> 2x BACKSPACE", + content: "<p><br></p>", + steps: [{ + start: "p->1", + key: 'ENTER', + shiftKey: false + }, { + key: 'ENTER', + shiftKey: false + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p><br></p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in empty-p (p before): ENTER -> 2x BACKSPACE", + content: "<p>dom not to edit</p><p><br></p>", + steps: [{ + start: "p:eq(1)->1", + key: 'ENTER', + shiftKey: false + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p>dom not to edit</p>", + start: "p:first:contents()[0]->15", + }, + }, + { + name: "in p > b: 2x SHIFT+ENTER -> BACKSPACE -> 'a'", + content: "<p><b>dom to edit</b></p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'ENTER', + shiftKey: true, + }, { + key: 'ENTER', + shiftKey: true, + }, { + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<p><b>dom<br>a to edit</b></p>", + start: "b:contents()[2]->1", + }, + }, + { + name: "in li -> ENTER before br -> 'a'", + content: "<ul><li><p>dom<br/> to edit</p></li></ul>", + steps: [{ + start: "p:contents()[0]->3", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<ul><li><p>dom</p></li><li><p>a<br> to edit</p></li></ul>", + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "in li -> ENTER after br -> 'a'", + content: "<ul><li><p>dom<br/> to edit</p></li></ul>", + steps: [{ + start: "p:contents()[2]->0", + key: 'ENTER', + }, { + key: 'a', + }], + test: { + content: "<ul><li><p>dom<br>​</p></li><li><p>a to edit</p></li></ul>", + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "within p (p before and after): 3x SHIFT+ENTER -> 3x BACKSPACE", + content: "<p>dom not to edit</p><p>dom to edit</p><p>dom not to edit</p>", + steps: [{ + start: "p:eq(1):contents()[0]->5", + key: 'ENTER', + shiftKey: true, + }, + { + key: 'ENTER', + shiftKey: true, + }, + { + key: 'ENTER', + shiftKey: true, + }, + { + key: 'BACKSPACE', + }, + { + key: 'BACKSPACE', + }, + { + key: 'BACKSPACE', + } + ], + test: { + content: "<p>dom not to edit</p><p>dom to edit</p><p>dom not to edit</p>", + start: "p:eq(1):contents()[0]->5", + }, + }, + { + name: "in h1.a > font: 'a' on selection of all contents", + content: "<h1 class=\"a\"><font style=\"font-size: 62px;\">dom to edit</font></h1>", + steps: [{ + start: "h1->0", + end: "h1->1", + key: 'a', + }], + test: { + content: "<h1 class=\"a\"><font style=\"font-size: 62px;\">a</font></h1>", + start: "font:contents()[0]->1", + }, + }, + { + name: "in complex-dom: BACKSPACE on partial selection (requires merging non-similar blocks)", + content: "<p class=\"a\">pif</p><p><span><b>paf</b></span></p><ul><li><p>p<i>ouf</i></p></li></ul>", + steps: [{ + start: 'p:contents()[0]->2', + end: 'i:contents()[0]->2', + key: 'BACKSPACE', + }], + test: { + content: '<p class=\"a\">pi<i>f</i></p>', + start: "p:contents()[0]->2", + }, + }, + { + name: "in complex-dom: DELETE on partial selection (requires merging non-similar blocks)", + content: "<p class=\"a\">pif</p><p><span><b>paf</b></span></p><ul><li><p>p<i>ouf</i></p></li></ul>", + steps: [{ + start: 'p:contents()[0]->2', + end: 'i:contents()[0]->2', + key: 'DELETE', + }], + test: { + content: '<p class=\"a\">pi<i>f</i></p>', + start: "p:contents()[0]->2", + }, + }, + + // paragraph outdent + + { + name: "in p (h1 before): TAB -> BACKSPACE at start (must indent then outdent)", + content: "<h1>dom not to edit</h1><p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'TAB', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<h1>dom not to edit</h1><p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in p (h1 before): TAB -> 2x BACKSPACE at start (must indent then outdent, then merge blocks)", + content: "<h1>dom not to edit</h1><p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'TAB', + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<h1>dom not to editdom to edit</h1>", + start: "h1:contents()[0]->15", + }, + }, +]; + +QUnit.test('Complex', function (assert) { + var done = assert.async(); + weTestUtils.createWysiwyg({ + data: this.data, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + weTestUtils.testKeyboard($editable, assert, keyboardTestsComplex).then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +var keyboardTestsTab = [{ + name: "in li: TAB at start", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'TAB', + }], + test: { + content: "<ul><li class=\"o_indent\"><ul><li>dom to edit</li></ul></li></ul>", + start: "ul ul li:contents()[0]->0", + }, + }, + { + name: "in indented-li: SHIFT+TAB at start", + content: "<ul><ul><li>dom to edit</li></ul></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'TAB', + shiftKey: true, + }], + test: { + content: "<ul><li>dom to edit</li></ul>", + start: "li:contents()[0]->0", + }, + }, + { + name: "in li: TAB within contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->5", + key: 'TAB', + }], + test: { + content: "<ul><li>dom t o edit</li></ul>", + start: "li:contents()[0]->9", + }, + }, + { + name: "in li: SHIFT+TAB within contents (should do nothing)", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->5", + key: 'TAB', + shiftKey: true, + }], + test: { + content: "<ul><li>dom to edit</li></ul>", + start: "li:contents()[0]->5", + }, + }, + { + name: "in td > p: TAB within contents", + content: "<table><tbody><tr><td><p>dom to edit</p></td><td><p>node</p></td></tr></tbody></table>", + steps: [{ + start: "p:contents()[0]->5", + key: 'TAB', + }], + test: { + content: "<table><tbody><tr><td><p>dom to edit</p></td><td><p>node</p></td></tr></tbody></table>", + start: "td:eq(1) p:contents(0)->0", + }, + }, + { + name: "in p: TAB at start", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'TAB', + }], + test: { + content: "<p style=\"margin-left: 1.5em;\">dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in indented-p: SHIFT+TAB at start", + content: "<p style=\"margin-left: 1.5em;\">dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'TAB', + shiftKey: true, + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in p: TAB -> BACKSPACE at start", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'TAB', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in p > b: TAB at start", + content: "<p>dom <b>to edit</b></p>", + steps: [{ + start: "b:contents()[0]->0", + key: 'TAB', + }], + test: { + content: "<p>dom <b> to edit</b></p>", + start: "b:contents()[0]->4", + }, + }, + { + name: "in p > b: TAB -> BACKSPACE at start", + content: "<p>dom <b>to edit</b></p>", + steps: [{ + start: "b:contents()[0]->0", + key: 'TAB', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p>dom <b> to edit</b></p>", + start: "b:contents()[0]->3", + }, + }, +]; + +QUnit.test('Tab', function (assert) { + var done = assert.async(); + weTestUtils.createWysiwyg({ + data: this.data, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + weTestUtils.testKeyboard($editable, assert, keyboardTestsTab).then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +var keyboardTestsBackspace = [{ + name: "in p: BACKSPACE after span.fa", + content: '<p>aaa<span class="fa fa-star"></span>bbb</p>', + steps: [{ + start: "p:contents()[2]->0", + key: 'BACKSPACE', + }], + test: { + content: '<p>aaabbb</p>', + start: "p:contents()[0]->3", + }, + }, + { + name: "in empty-p (no br): BACKSPACE (must insert br)", + content: "<p></p>", + steps: [{ + start: "p->0", + key: 'BACKSPACE', + }], + test: { + content: "<p><br></p>", // The br is there to ensure the carret can enter the p tag + start: "br->0", + }, + }, + { + name: "in empty-p: BACKSPACE (must leave it unchanged)", + content: "<p><br></p>", + steps: [{ + start: "p->1", + key: 'BACKSPACE', + }], + test: { + content: "<p><br></p>", // The br is there to ensure the carret can enter the p tag + start: "br->0", + }, + }, + { + name: "in p (empty-p before): BACKSPACE", + content: "<p><br></p><p>dom to edit</p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in p (empty-p.a before): BACKSPACE", + content: "<p class=\"a\"><br></p><p>dom to edit</p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in p: BACKSPACE within text", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom o edit</p>", // The br is there to ensure the carret can enter the p tag + start: "p:contents()[0]->4", + }, + }, + { + name: "in p: 2x BACKSPACE within text", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p>domo edit</p>", // The br is there to ensure the carret can enter the p tag + start: "p:contents()[0]->3", + }, + }, + { + name: "in p (p > span.a before - span.b after): BACKSPACE at beginning (must attach them)", + content: "<p><span class=\"a\">dom to</span></p><p><span class=\"b\">edit</span></p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p><span class=\"a\">dom to</span><span class=\"b\">edit</span></p>", + start: "span:eq(0):contents()[0]->6", + }, + }, + { + name: "in p (p > span.a before - span.a after): BACKSPACE (must merge them)", + content: "<p><span class=\"a\">dom to </span></p><p><span class=\"a\">edit</span></p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p><span class=\"a\">dom to edit</span></p>", + start: "span:contents()[0]->7", + }, + }, + { + name: "in p (b before): BACKSPACE", + content: "<p><b>dom</b> to edit</p>", + steps: [{ + start: "p:contents()[1]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p><b>do</b> to edit</p>", + start: "b:contents()[0]->2", + }, + }, + { + name: "in p (div > span.a before - span.a after): BACKSPACE at beginning (must do nothing)", + content: "<div><span class=\"a\">dom to </span></div><p><span class=\"a\">edit</span></p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<div><span class=\"a\">dom to </span></div><p><span class=\"a\">edit</span></p>", + start: "span:eq(1):contents()[0]->0", + }, + }, + { + name: "in p: BACKSPACE on partial selection", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + end: "p:contents()[0]->7", + key: 'BACKSPACE', + }], + test: { + content: "<p>dedit</p>", + start: "p:contents()[0]->1", + }, + }, + { + name: "across 2 p's: BACKSPACE on partial selection", + content: "<p>dom</p><p>to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + end: "p:eq(1):contents()[0]->3", + key: 'BACKSPACE', + }], + test: { + content: "<p>dedit</p>", + start: "p:contents()[0]->1", + }, + }, + { + name: "in p: BACKSPACE within text, with space at beginning", + content: "<p> dom to edit</p>", + steps: [{ + start: "p:contents()[0]->10", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom o edit</p>", + start: "p:contents()[0]->4", + }, + }, + { + name: "in p: BACKSPACE within text, with one space at beginning", + content: "<p> dom to edit</p>", + steps: [{ + start: "p:contents()[0]->6", + key: 'BACKSPACE', + }], + test: { + content: "<p> dom o edit</p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in p: BACKSPACE within text, with space at end", + content: "<p>dom to edit </p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom o edit</p>", + start: "p:contents()[0]->4", + }, + }, + { + name: "in p: BACKSPACE within text, with one space at end", + content: "<p>dom to edit </p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom o edit </p>", + start: "p:contents()[0]->4", + }, + }, + { + name: "in p: BACKSPACE within text, with space at beginning and end", + content: "<p> dom to edit </p>", + steps: [{ + start: "p:contents()[0]->10", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom o edit</p>", + start: "p:contents()[0]->4", + }, + }, + { + name: "in p: BACKSPACE within text, with one space at beginning and one at end", + content: "<p> dom to edit </p>", + steps: [{ + start: "p:contents()[0]->6", + key: 'BACKSPACE', + }], + test: { + content: "<p> dom o edit </p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in p: BACKSPACE after \\w<br>\\w", + content: "<p>dom to edi<br>t</p>", + steps: [{ + start: "p:contents()[2]->1", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom to edi<br>​</p>", + start: "p:contents()[2]->1", + }, + }, + { + name: "in p: BACKSPACE -> 'a' within text, after \\s\\w", + content: "<p>dom t</p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<p>dom a</p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in pre: BACKSPACE within text, with space at beginning", + content: "<pre> dom to edit</pre>", + steps: [{ + start: "pre:contents()[0]->10", + key: 'BACKSPACE', + }], + test: { + content: "<pre> dom o edit</pre>", + start: "pre:contents()[0]->9", + }, + }, + { + name: "in pre: BACKSPACE within text, with one space at beginning", + content: "<pre> dom to edit</pre>", + steps: [{ + start: "pre:contents()[0]->6", + key: 'BACKSPACE', + }], + test: { + content: "<pre> dom o edit</pre>", + start: "pre:contents()[0]->5", + }, + }, + { + name: "in pre: BACKSPACE within text, with space at end", + content: "<pre>dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->5", + key: 'BACKSPACE', + }], + test: { + content: "<pre>dom o edit </pre>", + start: "pre:contents()[0]->4", + }, + }, + { + name: "in pre: BACKSPACE within text, with one space at end", + content: "<pre>dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->5", + key: 'BACKSPACE', + }], + test: { + content: "<pre>dom o edit </pre>", + start: "pre:contents()[0]->4", + }, + }, + { + name: "in pre: BACKSPACE within text, with space at beginning and end", + content: "<pre> dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->10", + key: 'BACKSPACE', + }], + test: { + content: "<pre> dom o edit </pre>", + start: "pre:contents()[0]->9", + }, + }, + { + name: "in pre: BACKSPACE within text, with one space at beginning and one at end", + content: "<pre> dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->6", + key: 'BACKSPACE', + }], + test: { + content: "<pre> dom o edit </pre>", + start: "pre:contents()[0]->5", + }, + }, + + // list UL / OL + + { + name: "from p to ul > li: BACKSPACE on whole list", + content: "<p>dom not to edit</p><ul><li><p>dom to edit</p></li></ul>", + steps: [{ + start: "p:contents()[0]->15", + end: "p:eq(1):contents()[0)->11", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom not to edit</p>", + start: "p:contents()[0]->15", + }, + }, + { + name: "in ul > second-li > p: BACKSPACE within text", + content: "<ul><li><p>dom to</p></li><li><p>edit</p></li></ul>", + steps: [{ + start: "p:eq(1):contents()[0]->4", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><p>dom to</p></li><li><p>edi</p></li></ul>", + start: "p:eq(1):contents()[0]->3", + }, + }, + { + name: "in ul > second-li > empty-p: BACKSPACE at beginning", + content: "<ul><li><p><br></p></li><li><p><br></p></li></ul>", + steps: [{ + start: "p:eq(1)->1", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><p><br></p></li></ul>", + start: "p:first:contents()[0]->0", + }, + }, + { + name: "in ul > indented-li (no other li - p before): BACKSPACE at beginning", + content: "<p>dom to</p><ul><ul><li>edit</li></ul></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom to</p><ul><li>edit</li></ul>", + start: "li:contents()[0]->0", + }, + }, + { + name: "in ul > indented-li (no other li - p before): BACKSPACE -> 'a' at beginning", + content: "<p>dom to</p><ul><ul><li>edit</li></ul></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<p>dom to</p><ul><li>aedit</li></ul>", + start: "li:contents()[0]->1", + }, + }, + { + name: "in ul > indented-li (no other li - none before): BACKSPACE at beginning", + content: "<ul><ul><li>dom to edit</li></ul></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li>dom to edit</li></ul>", + start: "li:contents()[0]->0", + }, + }, + { + name: "in li: BACKSPACE on partial selection", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->1", + end: "li:contents()[0]->7", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li>dedit</li></ul>", + start: "li:contents()[0]->1", + }, + }, + { + name: "across 2 li: BACKSPACE on partial selection", + content: "<ul><li>dom to edit</li><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->1", + end: "li:eq(1):contents()[0]->7", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li>dedit</li></ul>", + start: "li:contents()[0]->1", + }, + }, + { + name: "in li (no other li): BACKSPACE on selection of all contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + end: "li:contents()[0]->11", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><br></li></ul>", + start: "li->0", + }, + }, + { + name: "in li (no other li): BACKSPACE -> 'a' on selection of all contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + end: "li:contents()[0]->11", + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<ul><li><p>a</p></li></ul>", + start: "p:contents()[0]->1", + }, + }, + { + name: "in empty-li: BACKSPACE (must remove list)", + content: "<ul><li><br></li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p><br></p>", + start: "br->0", + }, + }, + { + name: "in empty-li (no other li - empty-p before): BACKSPACE -> 'a'", + content: "<p><br></p><ul><li><br></li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<p><br></p><p>a</p>", + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "in empty-li (no other li - p before): BACKSPACE", + content: "<p>toto</p><ul><li><br></li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p>toto</p><p><br></p>", + start: "br->0", + }, + }, + { + name: "in li (no other li - p before): BACKSPACE at start", + content: "<p>toto</p><ul><li> <img src='/web_editor/static/src/img/transparent.png'></li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p>toto</p><p> <img data-src=\"/web_editor/static/src/img/transparent.png\"></p>", + start: "p:eq(1):contents()[0]->0", + }, + }, + { + name: "in empty-indented-li (other li - no other indented-li): BACKSPACE", + content: "<ul><li><p>toto</p></li><ul><li><br></li></ul><li><p>tutu</p></li></ul>", + steps: [{ + start: "ul ul li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><p>toto</p></li><li><br></li><li><p>tutu</p></li></ul>", + start: "li:eq(1) br->0", + }, + }, + { + name: "in empty-indented-li (other li - other indented-li): BACKSPACE", + content: "<ul><li><p>toto</p></li><ul><li><br></li><li><br></li></ul><li><p>tutu</p></li></ul>", + steps: [{ + start: "ul ul li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><p>toto</p></li><li><br></li><ul><li><br></li></ul><li><p>tutu</p></li></ul>", + start: "li:eq(1) br->0", + }, + }, + { + name: "in empty-indented-li (no other li, no other indented-li): BACKSPACE", + content: "<ul><ul><li><br></li></ul></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><br></li></ul>", + start: "br->0", + }, + }, + { + name: "in indented-li (other li, other indented-li): BACKSPACE at start", + content: "<ul><li><p>toto</p></li><ul><li><p>xxx</p></li><li><p>yyy</p></li></ul><li><p>tutu</p></li></ul>", + steps: [{ + start: "ul ul li:contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><p>toto</p></li><li><p>xxx</p></li><ul><li><p>yyy</p></li></ul><li><p>tutu</p></li></ul>", + start: "p:eq(1):contents()[0]->0", + }, + }, + { + name: "in li > second-p: BACKSPACE at start", + content: "<ul><li><p>toto</p></li><li><p>xxx</p><p>yyy</p></li><li><p>tutu</p></li></ul>", + steps: [{ + start: "li:eq(1) p:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><p>toto</p></li><li><p>xxxyyy</p></li><li><p>tutu</p></li></ul>", + start: "li:eq(1) p:contents()[0]->3", + }, + }, + { + name: "in li (li after): BACKSPACE at start, with spaces", + content: "<p>dom to edit </p><ul><li><p> dom to edit</p></li><li><p>dom not to edit</p></li></ul>", + steps: [{ + start: "p:eq(1):contents()[0]->6", + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<p>dom to edit dom to edit</p><ul><li><p>dom not to edit</p></li></ul>", + start: "p:contents()[0]->12", + }, + }, + { + name: "in li > p: BACKSPACE after single character", + content: "<ul><li><p>a</p></li></ul>", + steps: [{ + start: "p:contents()[0]->1", + key: 'BACKSPACE', + }], + test: { + content: "<ul><li><p><br></p></li></ul>", + start: "br->0", + }, + }, + { + name: "in li > p: BACKSPACE -> 'a' after single character", + content: "<ul><li><p>a</p></li></ul>", + steps: [{ + start: "p:contents()[0]->1", + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<ul><li><p>a</p></li></ul>", + start: "p:contents()[0]->1", + }, + }, + + // end list UL / OL + + { + name: "in p: BACKSPACE on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'BACKSPACE', + }], + test: { + content: "<p><br></p>", + start: "p->0", + }, + }, + { + name: "in p: BACKSPACE -> 'a' on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: "<p>a</p>", + start: "p:contents()[0]->1", + }, + }, + { + name: "in complex-dom: BACKSPACE on selection of most contents", + content: "<p><b>dom</b></p><p><b>to<br>partially</b>re<i>m</i>ove</p>", + steps: [{ + start: "b:contents()[0]->2", + end: "i:contents()[0]->1", + key: 'BACKSPACE', + }], + test: { + content: "<p><b>do</b>ove</p>", + start: "b:contents()[0]->2", + }, + }, + { + name: "in complex-dom: BACKSPACE on selection of all contents", + content: "<p><b>dom</b></p><p><b>to<br>completely</b>remov<i>e</i></p>", + steps: [{ + start: "p:contents()[0]->0", + end: "i:contents()[0]->1", + key: 'BACKSPACE', + }], + test: { + content: "<p><br></p>", + start: "br->0", + }, + }, + { + name: "in p: BACKSPACE after br", + content: "<p>dom <br>to edit</p>", + steps: [{ + start: "p:contents()[2]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->4", + }, + }, + { + name: "in complex-dom (span > b -> ENTER in contents)", + content: "<span><b>dom<br></b></span><br><span><b> to edit</b></span>", + steps: [{ + start: "b:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<span><b>dom<br> to edit</b></span>", + start: "b:contents(2)->0", + }, + }, + { + name: "in complex-dom (span > b -> ENTER in contents): 2 x BACKSPACE", + content: "<span><b>dom<br></b></span><br><span><b>a to edit</b></span>", + steps: [{ + start: "b:eq(1):contents()[0]->1", + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<span><b>dom<br> to edit</b></span>", + start: "b:contents(2)->0", + }, + }, + { + name: "in p (hr before): BACKSPACE", + content: '<p>aaa</p><hr><p>bbb</p>', + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: '<p>aaabbb</p>', + start: "p:contents()[0]->3", + }, + }, + { + name: "in p with multi br[id] (p before and after) (1)", + content: '<p>dom not to edit</p><p><br id="br-1"><br id="br-2"><br id="br-3"><br id="br-4"></p><p>dom not to edit</p>', + steps: [{ + start: "p:eq(1)->2", + key: 'BACKSPACE', + }], + test: { + content: '<p>dom not to edit</p><p><br id="br-1"><br id="br-3"><br id="br-4"></p><p>dom not to edit</p>', + start: "p:eq(1)->1", + }, + }, + { + name: "in p with multi br[id] (p before and after) (2)", + content: '<p>dom not to edit</p><p><br id="br-1"><br id="br-2"><br id="br-3"><br id="br-4"></p><p>dom not to edit</p>', + steps: [{ + start: "br:eq(2)->0", + key: 'BACKSPACE', + }], + test: { + content: '<p>dom not to edit</p><p><br id="br-1"><br id="br-3"><br id="br-4"></p><p>dom not to edit</p>', + start: "p:eq(1)->1", + }, + }, + { + name: "in p with multi br[id] (p before and after): 2x BACKSPACE", + content: '<p>dom not to edit</p><p><br id="br-1"><br id="br-2"><br id="br-3"><br id="br-4"></p><p>dom not to edit</p>', + steps: [{ + start: "p:eq(1)->2", + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: '<p>dom not to edit</p><p><br id="br-3"><br id="br-4"></p><p>dom not to edit</p>', + start: "p:eq(1)->0", + }, + }, + { + name: "in p with multi br (p before and after) (1)", + content: '<p>dom not to edit</p><p><br><br><br><br></p><p>dom not to edit</p>', + steps: [{ + start: "p:eq(1)->2", + key: 'BACKSPACE', + }], + test: { + content: '<p>dom not to edit</p><p><br><br><br></p><p>dom not to edit</p>', + start: "p:eq(1)->1", + }, + }, + { + name: "in p with multi br (p before and after) (2)", + content: '<p>dom not to edit</p><p><br><br><br><br></p><p>dom not to edit</p>', + steps: [{ + start: "br:eq(2)->0", + key: 'BACKSPACE', + }], + test: { + content: '<p>dom not to edit</p><p><br><br><br></p><p>dom not to edit</p>', + start: "p:eq(1)->1", + }, + }, + { + name: "in p with multi br (p before and after): 2x BACKSPACE", + content: '<p>dom not to edit</p><p><br><br><br><br></p><p>dom not to edit</p>', + steps: [{ + start: "p:eq(1)->2", + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: '<p>dom not to edit</p><p><br><br></p><p>dom not to edit</p>', + start: "p:eq(1)->0", + }, + }, + { + name: "in p: BACKSPACE within text after \w+<br>", + content: '<p>dom to<br>edit</p>', + steps: [{ + start: "p:contents()[2]->3", + key: 'BACKSPACE', + }], + test: { + content: '<p>dom to<br>edt</p>', + start: "p:contents()[2]->2", + }, + }, + + // table + + { + name: "in empty-td (td before): BACKSPACE -> 'a' at start", + content: '<table class="table table-bordered"><tbody><tr><td><p><br></p></td><td><p><br></p></td></tr></tbody></table>', + steps: [{ + start: "p:eq(1)->1", + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p><br></p></td><td><p>a</p></td></tr></tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "in td (td before): 2x BACKSPACE -> 'a' after first character", + content: '<table class="table table-bordered"><tbody><tr><td><p>dom not to edit</p></td><td><p>dom to edit</p></td></tr></tbody></table>', + steps: [{ + start: "p:eq(1):contents()[0]->1", + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p>dom not to edit</p></td><td><p>aom to edit</p></td></tr></tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + { + name: "in td (no other td): BACKSPACE within text", + content: '<table class="table table-bordered"><tbody><tr><td><p>dom to edit</p></td></tr></tbody></table>', + steps: [{ + start: "p:contents()[0]->5", + key: 'BACKSPACE', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p>dom o edit</p></td></tr></tbody></table>', + start: "p:contents()[0]->4", + }, + }, + { + name: "in complex-dom (empty-td (td before) -> 2x SHIFT-ENTER): 3x BACKSPACE", + content: '<table class="table table-bordered"><tbody><tr><td><p>dom not to edit</p></td><td><p><br><br><br></p></td></tr></tbody></table>', + steps: [{ + start: 'p:eq(1)->3', + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p>dom not to edit</p></td><td><p><br></p></td></tr></tbody></table>', + start: "p:eq(1)->0", + }, + }, + { + name: "in complex-dom (empty-td (td before) -> 2x SHIFT-ENTER): 3x BACKSPACE -> 'a'", + content: '<table class="table table-bordered"><tbody><tr><td><p>dom not to edit</p></td><td><p><br><br><br></p></td></tr></tbody></table>', + steps: [{ + start: 'p:eq(1)->3', + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }, { + key: 'a', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p>dom not to edit</p></td><td><p>a</p></td></tr></tbody></table>', + start: "p:eq(1):contents()[0]->1", + }, + }, + + // merging non-similar blocks + + { + name: "in p (h1 before): BACKSPACE at start", + content: '<h1>node to merge with</h1><p>node to merge</p>', + steps: [{ + start: 'p:contents()[0]->0', + key: 'BACKSPACE', + }], + test: { + content: '<h1>node to merge withnode to merge</h1>', + start: "h1:contents()[0]->18", + }, + }, + { + name: "in empty-p (h1 before): BACKSPACE", + content: "<h1>dom to edit</h1><p><br></p>", + steps: [{ + start: "p->1", + key: 'BACKSPACE', + }], + test: { + content: "<h1>dom to edit</h1>", + start: "h1:contents()[0]->11", + }, + }, + { + name: "in empty-p (h1 before): 2x BACKSPACE", + content: "<h1>dom to edit</h1><p><br></p>", + steps: [{ + start: "p->1", + key: 'BACKSPACE', + }, { + key: 'BACKSPACE', + }], + test: { + content: "<h1>dom to edi</h1>", + start: "h1:contents()[0]->10", + }, + }, + { + name: "in p (ul before): BACKSPACE at start", + content: '<ul><li><p>node to merge with</p></li></ul><p>node to merge</p>', + steps: [{ + start: 'p:eq(1):contents()[0]->0', + key: 'BACKSPACE', + }], + test: { + content: '<ul><li><p>node to merge withnode to merge</p></li></ul>', + start: "p:contents()[0]->18", + }, + }, + { + name: "in p > b (ul before, i after): BACKSPACE at start", + content: '<ul><li><p>node to merge with</p></li></ul><p><b>node</b><i> to merge</i></p>', + steps: [{ + start: 'b:contents()[0]->0', + key: 'BACKSPACE', + }], + test: { + content: '<ul><li><p>node to merge with<b>node</b><i> to merge</i></p></li></ul>', + start: "p:contents()[0]->18", + }, + }, + { + name: "in p > b (ul > i before, i after): BACKSPACE at start", + content: '<ul><li><p><i>node to merge with</i></p></li></ul><p><b>node</b><i> to merge</i></p>', + steps: [{ + start: 'b:contents()[0]->0', + key: 'BACKSPACE', + }], + test: { + content: '<ul><li><p><i>node to merge with</i><b>node</b><i> to merge</i></p></li></ul>', + start: "i:contents()[0]->18", + }, + }, + { + name: "in p.c (p.a > span.b before - span.b after): BACKSPACE at beginning", + content: "<p class=\"a\"><span class=\"b\">dom to </span></p><p class=\"c\"><span class=\"b\">edit</span></p>", + steps: [{ + start: "p:eq(1):contents()[0]->0", + key: 'BACKSPACE', + }], + test: { + content: "<p class=\"a\"><span class=\"b\">dom to edit</span></p>", + start: "span:eq(0):contents()[0]->7", + }, + }, + { + name: "from h1 to p: BACKSPACE", + content: '<h1>node to merge with</h1><p>node to merge</p>', + steps: [{ + start: 'h1:contents()[0]->5', + end: 'p:contents()[0]->2', + key: 'BACKSPACE', + }], + test: { + content: '<h1>node de to merge</h1>', + start: "h1:contents()[0]->5", + }, + }, + { + name: "from h1 to p: BACKSPACE at start", + content: '<h1><b>node to merge with</b></h1><p>node to merge</p>', + steps: [{ + start: 'b:contents()[0]->0', + end: 'p:contents()[0]->2', + key: 'BACKSPACE', + }], + test: { + content: '<p>de to merge</p>', + start: "p:contents()[0]->0", + }, + }, +]; + +QUnit.test('Backspace', function (assert) { + var done = assert.async(); + weTestUtils.createWysiwyg({ + data: this.data, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + weTestUtils.testKeyboard($editable, assert, keyboardTestsBackspace).then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +var keyboardTestsDelete = [{ + name: "in empty-p (no br): DELETE (must insert br)", + content: "<p></p>", + steps: [{ + start: "p->0", + key: 'DELETE', + }], + test: { + content: "<p><br></p>", // The br is there to ensure the carret can enter the p tag + start: "br->0", + }, + }, + { + name: "in empty-p: DELETE (must leave it unchanged)", + content: "<p><br></p>", + steps: [{ + start: "p->0", + key: 'DELETE', + }], + test: { + content: "<p><br></p>", // The br is there to ensure the carret can enter the p tag + start: "p->0", + }, + }, + { + name: "in empty-p: DELETE (must leave it unchanged) (2)", + content: "<p>\n <br>\n</p>", + steps: [{ + start: "p->1", + key: 'DELETE', + }], + test: { + content: "<p>\n <br>\n</p>", // The br is there to ensure the carret can enter the p tag + start: "p:contents()[1]->0", + }, + }, + { + name: "in empty-p (p after): DELETE", + content: "<p><br></p><p>dom to edit</p>", + steps: [{ + start: "p->0", + key: 'DELETE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in empty-p (p after): DELETE (2)", + content: "<p>\n <br>\n</p><p>\n dom to edit</p>", + steps: [{ + start: "p:contents()[1]->0", + key: 'DELETE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in empty-p.a (p after): DELETE", + content: "<p class=\"a\"><br></p><p>dom to edit</p>", + steps: [{ + start: "p->0", + key: 'DELETE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in empty-p.a (p after): DELETE (2)", + content: "<p class=\"a\"><br></p><p>dom to edit</p>", + steps: [{ + start: "p->1", + key: 'DELETE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->0", + }, + }, + { + name: "in p: DELETE at start", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'DELETE', + }], + test: { + content: "<p>om to edit</p>", // The br is there to ensure the carret can enter the p tag + start: "p:contents()[0]->0", + }, + }, + { + name: "in p: 2x DELETE at start", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + key: 'DELETE', + }, { + key: 'DELETE', + }], + test: { + content: "<p>m to edit</p>", // The br is there to ensure the carret can enter the p tag + start: "p:contents()[0]->0", + }, + }, + { + name: "in p: DELETE within text", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'DELETE', + }], + test: { + content: "<p>dom t edit</p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in p: DELETE within text, with space at beginning", + content: "<p> dom to edit</p>", + steps: [{ + start: "p:contents()[0]->10", + key: 'DELETE', + }], + test: { + content: "<p>dom t edit</p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in p: DELETE within text, with one space at beginning", + content: "<p> dom to edit</p>", + steps: [{ + start: "p:contents()[0]->6", + key: 'DELETE', + }], + test: { + content: "<p> dom t edit</p>", + start: "p:contents()[0]->6", + }, + }, + { + name: "in p: DELETE within text, with space at end", + content: "<p>dom to edit </p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'DELETE', + }], + test: { + content: "<p>dom t edit</p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in p: DELETE within text, with one space at end", + content: "<p>dom to edit </p>", + steps: [{ + start: "p:contents()[0]->5", + key: 'DELETE', + }], + test: { + content: "<p>dom t edit </p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in p: DELETE within text, with space at beginning and end", + content: "<p> dom to edit </p>", + steps: [{ + start: "p:contents()[0]->10", + key: 'DELETE', + }], + test: { + content: "<p>dom t edit</p>", + start: "p:contents()[0]->5", + }, + }, + { + name: "in p: DELETE within text, with one space at beginning and one at end", + content: "<p> dom to edit </p>", + steps: [{ + start: "p:contents()[0]->6", + key: 'DELETE', + }], + test: { + content: "<p> dom t edit </p>", + start: "p:contents()[0]->6", + }, + }, + { + name: "in p: DELETE -> 'a' within text, before \\w\\s", + content: "<p>dom t</p>", + steps: [{ + start: "p:contents()[0]->2", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: "<p>doa t</p>", + start: "p:contents()[0]->3", + }, + }, + { + name: "in pre: DELETE within text, with space at beginning", + content: "<pre> dom to edit</pre>", + steps: [{ + start: "pre:contents()[0]->10", + key: 'DELETE', + }], + test: { + content: "<pre> dom t edit</pre>", + start: "pre:contents()[0]->10", + }, + }, + { + name: "in pre: DELETE within text, with one space at beginning", + content: "<pre> dom to edit</pre>", + steps: [{ + start: "pre:contents()[0]->6", + key: 'DELETE', + }], + test: { + content: "<pre> dom t edit</pre>", + start: "pre:contents()[0]->6", + }, + }, + { + name: "in pre: DELETE within text, with space at end", + content: "<pre>dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->5", + key: 'DELETE', + }], + test: { + content: "<pre>dom t edit </pre>", + start: "pre:contents()[0]->5", + }, + }, + { + name: "in pre: DELETE within text, with one space at end", + content: "<pre>dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->5", + key: 'DELETE', + }], + test: { + content: "<pre>dom t edit </pre>", + start: "pre:contents()[0]->5", + }, + }, + { + name: "in pre: DELETE within text, with space at beginning and end", + content: "<pre> dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->10", + key: 'DELETE', + }], + test: { + content: "<pre> dom t edit </pre>", + start: "pre:contents()[0]->10", + }, + }, + { + name: "in pre: DELETE within text, with one space at beginning and one at end", + content: "<pre> dom to edit </pre>", + steps: [{ + start: "pre:contents()[0]->6", + key: 'DELETE', + }], + test: { + content: "<pre> dom t edit </pre>", + start: "pre:contents()[0]->6", + }, + }, + + // list UL / OL + + { + name: "in empty-li (no other li): DELETE (must do nothing)", + content: "<ul><li><br></li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'DELETE', + }], + test: { + content: "<ul><li><br></li></ul>", + start: "li->0", + }, + }, + { + name: "from p to li > p: DELETE on whole list", + content: "<p>dom not to edit</p><ul><li><p>dom to edit</p></li></ul>", + steps: [{ + start: "p:contents()[0]->15", + end: "p:eq(1):contents()[0)->11", + key: 'DELETE', + }], + test: { + content: "<p>dom not to edit</p>", + start: "p:contents()[0]->15", + }, + }, + { + name: "in empty-li (no other li): DELETE -> 'a' (must write into it)", + content: "<ul><li><br></li></ul>", + steps: [{ + start: "li:contents()[0]->0", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: "<ul><li><p>a</p></li></ul>", + start: "p:contents()[0]->1", + }, + }, + { + name: "in li (li after): DELETE at end (must move contents of second li to carret)", + content: "<ul><li>dom to </li><li>edit</li></ul>", + steps: [{ + start: "li:contents()[0]->7", + key: 'DELETE', + }], + test: { + content: "<ul><li>dom to edit</li></ul>", + start: "li:contents()[0]->7", + }, + }, + { + name: "in li (li after): DELETE -> 'a' at end (must move contents of second li to carret, then write)", + content: "<ul><li>dom to </li><li>edit</li></ul>", + steps: [{ + start: "li:contents()[0]->7", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: "<ul><li>dom to aedit</li></ul>", + start: "li:contents()[0]->8", + }, + }, + { + name: "in li > p: DELETE before single character", + content: "<ul><li><p>a</p></li></ul>", + steps: [{ + start: "p:contents()[0]->0", + key: 'DELETE', + }], + test: { + content: "<ul><li><p><br></p></li></ul>", + start: "br->0", + }, + }, + { + name: "in li > p: DELETE -> 'a' before single character", + content: "<ul><li><p>a</p></li></ul>", + steps: [{ + start: "p:contents()[0]->0", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: "<ul><li><p>a</p></li></ul>", + start: "p:contents()[0]->1", + }, + }, + { + name: "in li > p (p after): DELETE at end", + content: "<ul><li><p>toto</p></li><li><p>xxx</p><p>yyy</p></li><li><p>tutu</p></li></ul>", + steps: [{ + start: "li:eq(1) p:eq(0):contents()[0]->3", + key: 'DELETE', + }], + test: { + content: "<ul><li><p>toto</p></li><li><p>xxxyyy</p></li><li><p>tutu</p></li></ul>", + start: "li:eq(1) p:eq(0):contents()[0]->3", + }, + }, + { + name: "in li > p (b before - p after): DELETE at end", + content: "<ul><li><p>toto</p></li><li><p><b>x</b>xx</p><p><b>y</b>yy</p></li><li><p>tutu</p></li></ul>", + steps: [{ + start: "li:eq(1) p:eq(0):contents()[1]->2", + key: 'DELETE', + }], + test: { + content: "<ul><li><p>toto</p></li><li><p><b>x</b>xx<b>y</b>yy</p></li><li><p>tutu</p></li></ul>", + start: "li:eq(1) p:eq(0):contents()[1]->2", + }, + }, + { + name: "in li > p (p.o_default_snippet_text after): DELETE at end", + content: '<ul><li><p>toto</p></li><li><p>xxx</p><p class="o_default_snippet_text">yyy</p></li><li><p>tutu</p></li></ul>', + steps: [{ + start: "li:eq(1) p:eq(0):contents()[0]->3", + key: 'DELETE', + }], + test: { + content: "<ul><li><p>toto</p></li><li><p>xxxyyy</p></li><li><p>tutu</p></li></ul>", + start: "li:eq(1) p:eq(0):contents()[0]->3", + }, + }, + { + name: "in indented-ul > li: DELETE at end (must do nothing)", + content: "<ul><ul><li>dom to edit</li></ul></ul>", + steps: [{ + start: "li:contents()[0]->11", + key: 'DELETE', + }], + test: { + content: "<ul><ul><li>dom to edit</li></ul></ul>", + start: "li:contents()[0]->11", + }, + }, + { + name: "in indented-ul > li: DELETE at end -> 'a' at end (must write)", + content: "<ul><ul><li>dom to edit</li></ul></ul>", + steps: [{ + start: "li:contents()[0]->11", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: "<ul><ul><li>dom to edita</li></ul></ul>", + start: "li:contents()[0]->12", + }, + }, + { + name: "in indented-ul > li (non-indented-li after): DELETE at end", + content: "<ul><ul><li>dom to edit</li></ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->11", + key: 'DELETE', + }], + test: { + content: "<ul><ul><li>dom to editdom to edit</li></ul></ul>", + start: "li:contents()[0]->11", + }, + }, + { + name: "in ul > li (indented-li after): DELETE at end", + content: "<ul><li>dom to edit</li><ul><li>dom to edit</li></ul></ul>", + steps: [{ + start: "li:contents()[0]->11", + key: 'DELETE', + }], + test: { + content: "<ul><li>dom to editdom to edit</li></ul>", + start: "li:contents()[0]->11", + }, + }, + { + name: "in li: DELETE on partial selection", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->1", + end: "li:contents()[0]->7", + key: 'DELETE', + }], + test: { + content: "<ul><li>dedit</li></ul>", + start: "li:contents()[0]->1", + }, + }, + { + name: "across 2 li: DELETE on partial selection", + content: "<ul><li>dom to edit</li><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->1", + end: "li:eq(1):contents()[0]->7", + key: 'DELETE', + }], + test: { + content: "<ul><li>dedit</li></ul>", + start: "li:contents()[0]->1", + }, + }, + { + name: "in li: DELETE on selection of all contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + end: "li:contents()[0]->11", + key: 'DELETE', + }], + test: { + content: "<ul><li><br></li></ul>", + start: "li->0", + }, + }, + { + name: "in li: DELETE -> 'a' on selection of all contents", + content: "<ul><li>dom to edit</li></ul>", + steps: [{ + start: "li:contents()[0]->0", + end: "li:contents()[0]->11", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: "<ul><li><p>a</p></li></ul>", + start: "p:contents()[0]->1", + }, + }, + + // end list UL / OL + + { + name: "in p (b before): DELETE", + content: "<p><b>dom</b>to edit</p>", + steps: [{ + start: "b:contents()[0]->3", + key: 'DELETE', + }], + test: { + content: "<p><b>dom</b>o edit</p>", + start: "p:contents(1)->0", + }, + }, + { + name: "in p > span.a (div > span.a after): DELETE at end (must do nothing)", + content: "<p><span class=\"a\">dom to</span></p><div><span class=\"a\">edit</span></div>", + steps: [{ + start: "span:contents()[0]->6", + key: 'DELETE', + }], + test: { + content: "<p><span class=\"a\">dom to</span></p><div><span class=\"a\">edit</span></div>", + start: "span:contents()[0]->6", + }, + }, + { + name: "in p: DELETE on partial selection", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + end: "p:contents()[0]->7", + key: 'DELETE', + }], + test: { + content: "<p>dedit</p>", + start: "p:contents()[0]->1", + }, + }, + { + name: "across 2 p: DELETE on partial selection", + content: "<p>dom</p><p>to edit</p>", + steps: [{ + start: "p:contents()[0]->1", + end: "p:eq(1):contents()[0]->3", + key: 'DELETE', + }], + test: { + content: "<p>dedit</p>", + start: "p:contents()[0]->1", + }, + }, + { + name: "in p: DELETE on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'DELETE', + }], + test: { + content: "<p><br></p>", + start: "p->0", + }, + }, + { + name: "in p: DELETE -> 'a' on selection of all contents", + content: "<p>dom to edit</p>", + steps: [{ + start: "p:contents()[0]->0", + end: "p:contents()[0]->11", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: "<p>a</p>", + start: "p:contents()[0]->1", + }, + }, + { + name: "in p: DELETE before br", + content: "<p>dom <br>to edit</p>", + steps: [{ + start: "p:contents()[0]->4", + key: 'DELETE', + }], + test: { + content: "<p>dom to edit</p>", + start: "p:contents()[0]->4", + }, + }, + { + name: "in complex-dom: DELETE on selection of most contents", + content: "<p><b>dom</b></p><p><b>to<br>partially</b>re<i>m</i>ove</p>", + steps: [{ + start: "b:contents()[0]->2", + end: "i:contents()[0]->1", + key: 'DELETE', + }], + test: { + content: "<p><b>do</b>ove</p>", + start: "b:contents()[0]->2", + }, + }, + { + name: "in complex-dom: DELETE on selection of all contents", + content: "<p><b>dom</b></p><p><b>to<br>completely</b>remov<i>e</i></p>", + steps: [{ + start: "p:contents()[0]->0", + end: "i:contents()[0]->1", + key: 'DELETE', + }], + test: { + content: "<p><br></p>", + start: "br->0", + }, + }, + { + name: "in complex-dom (span > b -> ENTER): DELETE", + content: "<span><b>dom</b></span><br><span><b> to edit</b></span>", + steps: [{ + start: "b:eq(0):contents()[0]->3", + key: 'DELETE', + }], + test: { + content: "<span><b>dom to edit</b></span>", + start: "b:contents()[0]->3", + }, + }, + { + name: "in p (span.fa after): DELETE", + content: '<p>aaa<span class="fa fa-star"></span>bbb</p>', + steps: [{ + start: "p:contents()[0]->3", + key: 'DELETE', + }], + test: { + content: '<p>aaabbb</p>', + start: "p:contents()[0]->3", + }, + }, + { + name: "in p (hr after): DELETE", + content: '<p>aaa</p><hr><p>bbb</p>', + steps: [{ + start: "p:eq(0):contents()[0]->3", + key: 'DELETE', + }], + test: { + content: '<p>aaabbb</p>', + start: "p:contents()[0]->3", + }, + }, + + // table + + { + name: "in empty-td (td after): DELETE -> 'a' at start", + content: '<table class="table table-bordered"><tbody><tr><td><p><br></p></td><td><p><br></p></td></tr></tbody></table>', + steps: [{ + start: "p->1", + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p>a</p></td><td><p><br></p></td></tr></tbody></table>', + start: "p:contents()[0]->1", + }, + }, + { + name: "in td (td after): 2x DELETE -> 'a' after first character", + content: '<table class="table table-bordered"><tbody><tr><td><p>dom to edit</p></td><td><p>dom not to edit</p></td></tr></tbody></table>', + steps: [{ + start: "p:contents()[0]->1", + key: 'DELETE', + }, { + key: 'DELETE', + }, { + key: 'a', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p>da to edit</p></td><td><p>dom not to edit</p></td></tr></tbody></table>', + start: "p:contents()[0]->2", + }, + }, + { + name: "in td: DELETE within text", + content: '<table class="table table-bordered"><tbody><tr><td><p>dom to edit</p></td></tr></tbody></table>', + steps: [{ + start: "p:contents()[0]->4", + key: 'DELETE', + }], + test: { + content: '<table class="table table-bordered"><tbody><tr><td><p>dom o edit</p></td></tr></tbody></table>', + start: "p:contents()[0]->4", + }, + }, + + // merging non-similar blocks + + { + name: "in p (ul after, with spaces): DELETE at end (must bring contents of first li to end of p)", + content: "<p>dom to edit </p><ul><li><p> dom to edit</p></li><li><p>dom not to edit</p></li></ul>", + steps: [{ + start: "p:contents()[0]->11", + key: 'DELETE', + }, { + key: 'DELETE', + }, { + key: 'DELETE', + }, { + key: 'DELETE', + }], + test: { + content: "<p>dom to editdom to edit</p><ul><li><p>dom not to edit</p></li></ul>", + start: "p:contents()[0]->11", + }, + }, + { + name: "in h1 (p after): DELETE at end", + content: '<h1>node to merge with</h1><p>node to merge</p>', + steps: [{ + start: 'h1:contents()[0]->18', + key: 'DELETE', + }], + test: { + content: '<h1>node to merge withnode to merge</h1>', + start: "h1:contents()[0]->18", + }, + }, + { + name: "in h1 (empty-p after): DELETE at end", + content: "<h1>dom to edit</h1><p><br></p>", + steps: [{ + start: "h1:contents()[0]->11", + key: 'DELETE', + }], + test: { + content: "<h1>dom to edit</h1>", + start: "h1:contents()[0]->11", + }, + }, + { + name: "in h1 (empty-p after): 2x DELETE at end", + content: "<h1>dom to edit</h1><p><br></p>", + steps: [{ + start: "h1:contents()[0]->11", + key: 'DELETE', + }, { + key: 'DELETE', + }], + test: { + content: "<h1>dom to edit</h1>", + start: "h1:contents()[0]->11", + }, + }, + { + name: "in li > p (p after): DELETE at end", + content: '<ul><li><p>node to merge with</p></li></ul><p>node to merge</p>', + steps: [{ + start: 'p:contents()[0]->18', + key: 'DELETE', + }], + test: { + content: '<ul><li><p>node to merge withnode to merge</p></li></ul>', + start: "p:contents()[0]->18", + }, + }, + { + name: "in li > p (i before - p > b after): DELETE at end", + content: '<ul><li><p><i>node to merge</i> with</p></li></ul><p><b>node</b> to merge</p>', + steps: [{ + start: 'p:contents()[1]->5', + key: 'DELETE', + }], + test: { + content: '<ul><li><p><i>node to merge</i> with<b>node</b> to merge</p></li></ul>', + start: "p:contents()[1]->5", + }, + }, + { + name: "in li > p > i (b before - p > b after): DELETE at end", + content: '<ul><li><p><b>node to </b><i>merge with</i></p></li></ul><p><b>node</b> to merge</p>', + steps: [{ + start: 'i:contents()[0]->10', + key: 'DELETE', + }], + test: { + content: '<ul><li><p><b>node to </b><i>merge with</i><b>node</b> to merge</p></li></ul>', + start: "i:contents()[0]->10", + }, + }, + { + name: "in p.a > span.b (p.c > span.b after): DELETE at end", + content: "<p class=\"a\"><span class=\"b\">dom to </span></p><p class=\"c\"><span class=\"b\">edit</span></p>", + steps: [{ + start: "span:contents()[0]->7", + key: 'DELETE', + }], + test: { + content: "<p class=\"a\"><span class=\"b\">dom to edit</span></p>", + start: "span:contents()[0]->7", + }, + }, +]; + +var keyboardTestsDeleteDOM = [ + '<div style="margin:0px; padding:0px;">', + ' <p style="margin:0px; padding:0px;">', + ' Dear Ready Mat', + ' <br><br>', + ' Here is', + ' the quotation <strong>SO003</strong>', + ' amounting in <strong>$ 1,127.50</strong>', + ' from YourCompany.', + ' <br><br>', + ' Do not hesitate to contact us if you have any question.', + ' <br><br>', + ' </p>', + ' <table width="100%">', + ' <tbody><tr>', + ' <td>', + ' <strong>Virtual Interior Design</strong><br>On Site ', + ' </td>', + ' <td width="100px" align="right">', + ' 10 Hour(s)', + ' </td>', + ' </tr>', + ' </tbody></table>', + '</div>', + ' ' +].join('\n'); +keyboardTestsDelete.push({ + name: "in complex-dom: DELETE text selection", + content: keyboardTestsDeleteDOM, + steps: [{ + start: "p:contents()[0]->20", + end: 'p:contents()[0]->23', + key: 'DELETE', + }, + { + start: "p:contents()[10]->55", + end: 'p:contents()[10]->63', + key: 'DELETE', + }, + ], + test: { + content: keyboardTestsDeleteDOM.replace('question', '').replace('Mat', ''), + start: "p:contents()[10]->55", + }, +}, { + name: "in complex-dom: DELETE br", + content: keyboardTestsDeleteDOM, + steps: [{ + start: "p->2", + key: 'DELETE', + }, ], + test: { + content: keyboardTestsDeleteDOM.replace('<br>', ''), + start: "p:contents()[2]->0", + }, +}, { + name: "in complex-dom: DELETE selection (all strong text content)", + content: keyboardTestsDeleteDOM, + steps: [{ + start: "strong:eq(0):contents()[0]->0", + end: 'strong:eq(0):contents()[0]->5', + key: 'DELETE', + }, ], + test: { + content: keyboardTestsDeleteDOM.replace(/<strong>SO003<\/strong>\s+/, ' '), + start: "p:contents()[3]->43", + }, +}, { + name: "in complex-dom: DELETE selection (in strong text content before br)", + content: keyboardTestsDeleteDOM, + steps: [{ + start: "strong:eq(2):contents()[0]->17", + end: 'strong:eq(2):contents()[0]->23', + key: 'DELETE', + }, ], + test: { + content: keyboardTestsDeleteDOM.replace(/Design/, ''), + start: "strong:eq(2):contents()[0]->17", + }, +}); + +QUnit.test('Delete', function (assert) { + var done = assert.async(); + weTestUtils.createWysiwyg({ + data: this.data, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + weTestUtils.testKeyboard($editable, assert, keyboardTestsDelete).then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +}); +}); +}); diff --git a/addons/web_editor/static/tests/wysiwyg_snippets_tests.js b/addons/web_editor/static/tests/wysiwyg_snippets_tests.js new file mode 100644 index 00000000000..2441897b36d --- /dev/null +++ b/addons/web_editor/static/tests/wysiwyg_snippets_tests.js @@ -0,0 +1,403 @@ +odoo.define('web_editor.wysiwyg_snippets_tests', function (require) { +"use strict"; + +var testUtils = require('web.test_utils'); +var weTestUtils = require('web_editor.test_utils'); +var MediaDialog = require('wysiwyg.widgets.MediaDialog'); + + +QUnit.module('web_editor', {}, function () { +QUnit.module('wysiwyg', {}, function () { +QUnit.module('Snippets', {}, function () { + +QUnit.test('drag&drop', function (assert) { + var done = assert.async(); + assert.expect(2); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<p>toto toto toto</p><p>tata</p>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + var $hr = wysiwyg.snippets.$('.oe_snippet_thumbnail:first'); + testUtils.dom.dragAndDrop($hr, $editable.find('p')); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>toto toto toto</p><div class=\"s_hr pt32 pb32\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> </div><p>tata</p>', + "should drop the snippet"); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>toto toto toto</p><div class=\"s_hr pt32 pb32 built focus\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> </div><p>tata</p>', + "should build and focus the snippet"); + + wysiwyg.destroy(); + done(); + }); + }); +}); + +QUnit.test('begin drag&drop must hide popovers', function (assert) { + var done = assert.async(); + assert.expect(2); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: + '<div class="s_hr pt32 pb32"> ' + + '<hr class="s_hr_1px s_hr_solid w-100 mx-auto"> ' + + '<img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png"> ' + + '</div>' + + '<h1>test</h1>' + + '<p><b>test</b></p>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + $editable.find('.s_hr img').mousedown().click(); + assert.strictEqual($('.note-popover:visible').length, 1, "should display the image popover"); + + var $hr = wysiwyg.snippets.$('.oe_snippet_thumbnail:first'); + testUtils.dom.dragAndDrop($hr, $editable.find('h1'), { + disableDrop: true, + }); + assert.strictEqual($('.note-popover:visible').length, 0, "should hide the image popover"); + testUtils.dom.dragAndDrop($hr, $editable.find('h1'), { + continueMove: true, + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('change size option must hide popovers', function (assert) { + var done = assert.async(); + assert.expect(4); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: + '<div class="s_hr pt32 pb32"> ' + + '<hr class="s_hr_1px s_hr_solid w-100 mx-auto"> ' + + '<img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png"> ' + + '</div>' + + '<h1>test</h1>' + + '<p><b>test</b></p>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + $editable.find('.s_hr img').mousedown().click(); + assert.strictEqual($('.note-popover:visible').length, 1, "should display the image popover"); + + var $handle = $('#oe_manipulators .o_handle.s'); + testUtils.dom.dragAndDrop($handle, $editable.find('b'), { + disableDrop: true, + }); + assert.ok($handle.hasClass('o_active'), "should active the handle"); + assert.notOk($handle.hasClass('pb32'), "should change the padding bottom"); + assert.strictEqual($('.note-popover:visible').length, 0, "should hide the image popover"); + testUtils.dom.dragAndDrop($handle, $editable.find('b'), { + continueMove: true, + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('clean the dom before save, after drag&drop', function (assert) { + var done = assert.async(); + assert.expect(1); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<p>toto toto toto</p><p>tata</p>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + var $hr = wysiwyg.snippets.$('.oe_snippet_thumbnail:first'); + testUtils.dom.dragAndDrop($hr, $editable.find('p')); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + wysiwyg.save().then(function (isDirty, html) { + assert.strictEqual(html.replace(/\s+/g, ' '), + '<p>toto toto toto</p><div class=\"s_hr pt32 pb32 built cleanForSave\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> </div><p>tata</p>', + "should clean the snippet"); + + wysiwyg.destroy(); + done(); + }); + }); + }); +}); + +QUnit.test('clean the dom before save', function (assert) { + var done = assert.async(); + assert.expect(1); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<div class="s_hr pt32 pb32"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + // add dirty flag to remove warning because the cleaned dom is different of the initial value and no dirty flag + $editable.find('.s_hr').keydown(); + + // trigger change to avoid warning because the DOM change on clean without trigger onchange by snippet or edition + $editable.trigger('content_changed'); + + wysiwyg.save().then(function (isDirty, html) { + assert.strictEqual(html.replace(/\s+/g, ' '), + '<div class=\"s_hr pt32 pb32 cleanForSave\"> <hr class=\"s_hr_1px s_hr_solid w-100 mx-auto\"> </div>', + "should clean the snippet"); + + wysiwyg.destroy(); + done(); + }); + }); + + $editable.find('.s_hr').mousedown().click(); + }); +}); + +QUnit.test('remove snippet', function (assert) { + var done = assert.async(); + assert.expect(1); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<div class="s_hr pt32 pb32"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + $('#oe_manipulators .oe_overlay_options .oe_snippet_remove').click(); + + wysiwyg.save().then(function (isDirty, html) { + assert.strictEqual(html.replace(/\s+/g, ' '), '', "should remove the snippet"); + + wysiwyg.destroy(); + done(); + }); + }); + + $editable.find('.s_hr').mousedown().click(); + }); +}); + +QUnit.test('move a snippet', function (assert) { + var done = assert.async(); + assert.expect(1); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<div class="s_hr pt32 pb32"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><h1>test</h1><p><b>test</b></p>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + var first = true; + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + if (first) { + first = false; + var $hr = $('#oe_manipulators .oe_overlay_options .oe_snippet_move'); + testUtils.dom.dragAndDrop($hr, $editable.find('b')); + } else { + wysiwyg.save().then(function (isDirty, html) { + assert.strictEqual(html.replace(/\s+/g, ' '), + '<h1>test</h1><div class="s_hr pt32 pb32 move cleanForSave"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><p><b>test</b></p>', + "should move the snippet on the bottom"); + + wysiwyg.destroy(); + done(); + }); + } + }); + + $editable.find('.s_hr').mousedown().click(); + }); +}); + +QUnit.test('clone a snippet', function (assert) { + var done = assert.async(); + assert.expect(2); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<div class="s_hr pt32 pb32"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><h1>test</h1><p><b>test</b></p>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + $('#oe_manipulators .oe_overlay_options .oe_snippet_clone').click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<div class="s_hr pt32 pb32 focus"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><div class="s_hr pt32 pb32 clone"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><h1>test</h1><p><b>test</b></p>', + "should duplicate the snippet"); + + wysiwyg.save().then(function (isDirty, html) { + assert.strictEqual(html.replace(/\s+/g, ' '), + '<div class="s_hr pt32 pb32 cleanForSave"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><div class="s_hr pt32 pb32 clone cleanForSave"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><h1>test</h1><p><b>test</b></p>', + "should duplicate the snippet"); + + wysiwyg.destroy(); + done(); + }); + }); + + $editable.find('.s_hr').mousedown().click(); + }); +}); + +QUnit.test('customize snippet', function (assert) { + var done = assert.async(); + assert.expect(3); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<h1>test</h1><div class="s_hr pt32 pb32"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><p><b>test</b></p>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + $('#oe_manipulators .oe_overlay_options .oe_options a:first').click(); + var $option = $('#oe_manipulators .oe_overlay_options a[data-select-class="align-items-center"]'); + + assert.strictEqual($option.size(), 1, "should display the snippet option"); + + $option.click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<h1>test</h1><div class="s_hr pt32 pb32 focus align-items-center"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><p><b>test</b></p>', + "should customized the snippet"); + + $('#oe_manipulators .oe_overlay_options .oe_options a:first').click(); + $('#oe_manipulators .oe_overlay_options a[data-select-class="align-items-end"]').click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<h1>test</h1><div class="s_hr pt32 pb32 focus align-items-end"> <hr class="s_hr_1px s_hr_solid w-100 mx-auto"> </div><p><b>test</b></p>', + "should twice customized the snippet"); + + wysiwyg.destroy(); + done(); + }); + + $editable.find('.s_hr').mousedown().click(); + }); +}); + +QUnit.test('background-image', function (assert) { + var done = assert.async(); + assert.expect(2); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: + '<h1>Big Title</h1>' + + '<section class="test_option_all pt32 pb32">' + + ' <div class="container">' + + ' <div class="row">' + + ' <div class="col-lg-10 offset-lg-1 pt32 pb32">' + + ' <h2>Title</h2>' + + ' <p class="lead o_default_snippet_text">Content</p>' + + ' </div>' + + ' </div>' + + ' </div>' + + '</section>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + var defMediaDialogInit = $.Deferred(); + var defMediaDialogSave = $.Deferred(); + testUtils.mock.patch(MediaDialog, { + init: function () { + this._super.apply(this, arguments); + this.opened(defMediaDialogInit.resolve.bind(defMediaDialogInit)); + }, + save: function () { + $.when(this._super.apply(this, arguments)).then(function () { + defMediaDialogSave.resolve(); + }); + }, + }); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + $('#oe_manipulators .oe_overlay_options .oe_options a:first').click(); + $('#oe_manipulators .oe_overlay_options a.snippet-option-background[data-choose-image]').click(); + + defMediaDialogInit.then(function () { + $('.modal.note-image-dialog .existing-attachments .o_image').click(); + $('.modal.note-image-dialog .modal-footer .btn-primary').click(); + }); + + defMediaDialogSave.then(function () { + var $snippet = $editable.find('.test_option_all'); + assert.ok($snippet.hasClass('oe_custom_bg'), "should use custom background"); + var background = $snippet.css('background-image') || ''; + assert.strictEqual(background.replace(/[^(\("'\/]+\/\/[^\("'\/]+/, ''), + 'url("/web_editor/static/src/img/transparent.png")', + "should change the background image"); + + testUtils.mock.unpatch(MediaDialog); + wysiwyg.destroy(); + done(); + }); + }); + + $editable.find('h2').mousedown().click(); + }); +}); + +QUnit.test('update content', function (assert) { + var done = assert.async(); + assert.expect(1); + + return weTestUtils.createWysiwyg({ + wysiwygOptions: { + snippets: true, + value: '<div class="s_hr pt32 pb32"> ' + + '<img class="img-fluid o_we_custom_image test_option_all" data-src="/web_editor/static/src/img/transparent.png"> ' + + '</div>', + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + testUtils.mock.intercept(wysiwyg, "snippet_focused", function () { + var $snippetOption = $('#oe_manipulators .oe_overlay_options:visible'); + wysiwyg.$el.siblings('.note-image-popover').find('.note-media .note-icon-trash').mousedown().click(); + assert.ok($snippetOption.is(':hidden'), "should remove the snippet editor because the target are removed"); + + wysiwyg.destroy(); + done(); + }); + + $editable.find('img').mousedown().click(); + }); +}); + +}); +}); +}); +}); diff --git a/addons/web_editor/static/tests/wysiwyg_tests.js b/addons/web_editor/static/tests/wysiwyg_tests.js new file mode 100644 index 00000000000..c2033848e67 --- /dev/null +++ b/addons/web_editor/static/tests/wysiwyg_tests.js @@ -0,0 +1,3859 @@ +odoo.define('web_editor.wysiwyg_tests', function (require) { +"use strict"; + +var AltDialog = require('wysiwyg.widgets.AltDialog'); +var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog'); +var CropDialog = require('wysiwyg.widgets.CropImageDialog'); +var LinkDialog = require('wysiwyg.widgets.LinkDialog'); +var MediaDialog = require('wysiwyg.widgets.MediaDialog'); +var testUtils = require('web.test_utils'); +var weTestUtils = require('web_editor.test_utils'); +var Wysiwyg = require('web_editor.wysiwyg'); + +var testName = ""; +var carretTestSuffix = " (carret position)"; + +QUnit.module('web_editor', { + afterEach: function () { + $('body').removeClass('modal-open'); + $('.note-popover, .modal, .modal-backdrop').remove(); + }, +}, function () { +QUnit.module('wysiwyg', {}, function () { +QUnit.module('toolbar', {}, function () { + +QUnit.test('Magic wand', function (assert) { + var done = assert.async(); + assert.expect(8); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $dropdownStyle = wysiwyg.$('.note-style .dropdown-toggle'); + var $btnsStyle = wysiwyg.$('.note-style .dropdown-menu .dropdown-item'); + + var wandTests = [{ + name: "Click H1: p -> h1", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownStyle.mousedown().click(); + $btnsStyle.find('h1').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><h1>dom to edit</h1>', + start: 'h1:contents()[0]->0', + end: 'h1:contents()[0]->11', + }, + }, + { + name: "Click CODE: h1 -> pre", + content: '<p>dom not to edit</p><h1>dom to edit</h1>', + start: 'h1:contents()[0]->1', + do: function () { + $dropdownStyle.mousedown().click(); + $btnsStyle.find('pre').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><pre>dom to edit</pre>', + start: 'pre:contents()[0]->0', + end: 'pre:contents()[0]->11', + }, + }, + { + name: "Click NORMAL: pre -> p", + content: '<p>dom not to edit</p><pre>dom to edit</pre>', + start: 'pre:contents()[0]->1', + do: function () { + $dropdownStyle.mousedown().click(); + $btnsStyle.find('p').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->0', + end: 'p:eq(1):contents()[0]->11', + }, + }, + { + name: "Click H1 in empty p: empty p -> empty h1", + content: '<p><br></p>', + start: 'p->1', + do: function () { + $dropdownStyle.mousedown().click(); + $btnsStyle.find('h1').mousedown().click(); + }, + test: { + content: '<h1><br></h1>', + start: 'h1:contents()[0]->0', + }, + }, + ]; + + _.each(wandTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('Font style', function (assert) { + var done = assert.async(); + assert.expect(56); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + generateOptions: function (options) { + options.toolbar[1][1] = ['bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript', 'clear']; + }, + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $btnBold = wysiwyg.$('.note-font .note-btn-bold'); + var $btnItalic = wysiwyg.$('.note-font .note-btn-italic'); + var $btnUnderline = wysiwyg.$('.note-font .note-btn-underline'); + var $strikethrough = wysiwyg.$('.note-font .note-icon-strikethrough'); + var $superscript = wysiwyg.$('.note-font .note-icon-superscript'); + var $subscript = wysiwyg.$('.note-font .note-icon-subscript'); + var $btnRemoveStyles = wysiwyg.$('.note-font .btn-sm .note-icon-eraser'); + + var styleTests = [ + /* BOLD */ + { + name: "Click BOLD: normal -> bold", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<b>om t</b>o edit</p>', + start: 'b:contents()[0]->0', + end: 'b:contents()[0]->4', + }, + }, + { + name: "Click BOLD then 'a': normal -> bold (empty p)", + content: '<p><br></p>', + start: 'p->1', + do: function () { + $btnBold.mousedown().click(); + weTestUtils.keydown('a', $editable); + }, + test: { + content: '<p><b>a</b></p>', + start: 'b:contents()[0]->1', + }, + }, + { + name: "Click BOLD: normal -> bold (across paragraphs)", + content: '<p>dom to edit</p><p>dom to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p>d<b>om to edit</b></p><p><b>dom t</b>o edit</p>', + start: 'b:contents()[0]->0', + end: 'b:eq(1):contents()[0]->5', + }, + }, + { + name: "Click BOLD then 'a': normal -> bold (no selection)", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->4', + do: function () { + $btnBold.mousedown().click(); + weTestUtils.keydown('a', $editable); + }, + test: { + content: '<p>dom not to edit</p><p>dom <b>a</b>to edit</p>', + start: 'b:contents()[0]->1', + }, + }, + { + name: "Click BOLD: bold -> normal", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->0', + end: 'b:contents()[0]->11', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->0', + end: 'p:eq(1):contents()[0]->11', + }, + }, + { + name: "Click BOLD: bold -> normal (partial selection)", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->4', + end: 'b:contents()[0]->6', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>dom </b>to<b> edit</b></p>', + start: 'p:eq(1):contents()[1]->0', + end: 'p:eq(1):contents()[1]->2', + }, + }, + { + name: "Click BOLD: bold -> normal (no selection)", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->4', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>dom </b>\u200B<b>to edit</b></p>', + start: 'p:eq(1):contents()[1]->1', + }, + }, + { + name: "Click BOLD: bold + normal -> normal", + content: '<p><b>dom </b>to edit</p>', + start: 'b:contents()[0]->1', + end: 'p:contents()[1]->4', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p><b>dom to e</b>dit</p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[0]->8', + }, + }, + { + name: "Click BOLD: normal -> bold (with fontawesome)", + content: '<p>aaa<span class="fa fa-heart"></span>bbb</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[2]->2', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p>a<b>aa<span class="fa fa-heart"></span>bb</b>b</p>', + start: 'b:contents()[0]->0', + end: 'b:contents()[2]->2', + }, + }, + { + name: "Click BOLD + click BOLD: normal -> bold -> normal (with fontawesome)", + content: '<p>aaa<span class="fa fa-heart"></span>bbb</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[2]->2', + do: function () { + $btnBold.mousedown().click(); + $btnBold.mousedown().click(); + }, + test: { + content: '<p>aaa<span class="fa fa-heart"></span>bbb</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[2]->2', + }, + }, + { + name: "Click BOLD: bold -> normal (with fontawesome)", + content: '<p><b>aaa<span class="fa fa-heart"></span>bbb</b></p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[2]->2', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p><b>a</b>aa<span class="fa fa-heart"></span>bb<b>b</b></p>', + start: 'p:contents()[1]->0', + end: 'p:contents()[3]->2', + }, + }, + /* ITALIC */ + { + name: "Click ITALIC: bold -> bold + italic", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[0]->5', + do: function () { + $btnItalic.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>d<i>om t</i>o edit</b></p>', + start: 'i:contents()[0]->0', + end: 'i:contents()[0]->4', + }, + }, + { + name: "Click ITALIC: bold & normal -> italic & bold + italic (across paragraphs)", + content: '<p>dom <b>to</b> edit</p><p><b>dom to edit</b></p>', + start: 'p:contents()[0]->1', + end: 'b:eq(1):contents()[0]->5', + do: function () { + $btnItalic.mousedown().click(); + }, + test: { + content: '<p>d<i>om <b>to</b> edit</i></p><p><b><i>dom t</i>o edit</b></p>', + start: 'i:contents()[0]->0', + end: 'i:eq(1):contents()[0]->5', + }, + }, + /* UNDERLINE */ + { + name: "Click UNDERLINE: bold -> bold + underlined", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[0]->5', + do: function () { + $btnUnderline.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>d<u>om t</u>o edit</b></p>', + start: 'u:contents()[0]->0', + end: 'u:contents()[0]->4', + }, + }, + { + name: "Click UNDERLINE: bold & normal -> underlined & bold + underlined (across paragraphs)", + content: '<p>dom <b>to</b> edit</p><p><b>dom to edit</b></p>', + start: 'p:contents()[0]->1', + end: 'b:eq(1):contents()[0]->5', + do: function () { + $btnUnderline.mousedown().click(); + }, + test: { + content: '<p>d<u>om <b>to</b> edit</u></p><p><b><u>dom t</u>o edit</b></p>', + start: 'u:contents()[0]->0', + end: 'u:eq(1):contents()[0]->5', + }, + }, + /* strikethrough */ + { + name: "Click strikethrough: bold -> bold + strikethrough", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[0]->5', + do: function () { + $strikethrough.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>d<s>om t</s>o edit</b></p>', + start: 's:contents()[0]->0', + end: 's:contents()[0]->4', + }, + }, + { + name: "Click strikethrough: bold & normal -> strikethrough & bold + strikethrough (across paragraphs)", + content: '<p>dom <b>to</b> edit</p><p><b>dom to edit</b></p>', + start: 'p:contents()[0]->1', + end: 'b:eq(1):contents()[0]->5', + do: function () { + $strikethrough.mousedown().click(); + }, + test: { + content: '<p>d<s>om <b>to</b> edit</s></p><p><b><s>dom t</s>o edit</b></p>', + start: 's:contents()[0]->0', + end: 's:eq(1):contents()[0]->5', + }, + }, + /* superscript */ + { + name: "Click superscript: bold -> bold + superscript", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[0]->5', + do: function () { + $superscript.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>d<sup>om t</sup>o edit</b></p>', + start: 'sup:contents()[0]->0', + end: 'sup:contents()[0]->4', + }, + }, + { + name: "Click superscript: bold & normal -> superscript & bold + superscript (across paragraphs)", + content: '<p>dom <b>to</b> edit</p><p><b>dom to edit</b></p>', + start: 'p:contents()[0]->1', + end: 'b:eq(1):contents()[0]->5', + do: function () { + $superscript.mousedown().click(); + }, + test: { + content: '<p>d<sup>om <b>to</b> edit</sup></p><p><b><sup>dom t</sup>o edit</b></p>', + start: 'sup:contents()[0]->0', + end: 'sup:eq(1):contents()[0]->5', + }, + }, + /* subscript */ + { + name: "Click subscript: bold -> bold + subscript", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[0]->5', + do: function () { + $subscript.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>d<sub>om t</sub>o edit</b></p>', + start: 'sub:contents()[0]->0', + end: 'sub:contents()[0]->4', + }, + }, + { + name: "Click subscript: bold & normal -> subscript & bold + subscript (across paragraphs)", + content: '<p>dom <b>to</b> edit</p><p><b>dom to edit</b></p>', + start: 'p:contents()[0]->1', + end: 'b:eq(1):contents()[0]->5', + do: function () { + $subscript.mousedown().click(); + }, + test: { + content: '<p>d<sub>om <b>to</b> edit</sub></p><p><b><sub>dom t</sub>o edit</b></p>', + start: 'sub:contents()[0]->0', + end: 'sub:eq(1):contents()[0]->5', + }, + }, + /* REMOVE FONT STYLE */ + { + name: "Click REMOVE FONT STYLE: bold -> normal", + content: '<p>dom not to edit</p><p><b>dom to edit</b></p>', + start: 'b:contents()[0]->1', + end: 'b:contents()[0]->5', + do: function () { + $btnRemoveStyles.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><b>d</b>om t<b>o edit</b></p>', + start: 'p:eq(1):contents()[1]->0', + end: 'p:eq(1):contents()[1]->4', + }, + }, + { + name: "Click REMOVE FONT STYLE: bold, italic, underlined & normal -> normal (across paragraphs)", + content: '<p>dom <b>t<i>o</i></b> e<u>dit</u></p><p><b><u>dom</u> to edit</b></p>', + start: 'p:contents()[0]->1', + end: 'u:eq(1):contents()[0]->3', + do: function () { + $btnRemoveStyles.mousedown().click(); + }, + test: { + content: '<p>dom to edit</p><p>dom<b> to edit</b></p>', + start: 'p:contents()[0]->1', + end: 'p:eq(1):contents()[0]->3', + }, + }, + { + name: "Click REMOVE FONT STYLE: complex -> normal", + content: '<p>aaa<font style="background-color: rgb(255, 255, 0);">bbb</font></p><p><font style="color: rgb(255, 0, 0);">ccc</font></p>', + start: 'p:contents()[0]->1', + end: 'font:eq(1):contents()[0]->1', + do: function () { + $btnRemoveStyles.mousedown().click(); + }, + test: { + content: '<p>aaabbb</p><p>c<font style="color: rgb(255, 0, 0);">cc</font></p>', + start: 'p:contents()[0]->1', + end: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click REMOVE FONT STYLE: complex -> normal (with icon)", + content: '<p>a<b>a</b>a<span class="bg-alpha text-alpha fa fa-heart" style="font-size: 10px;"></span>b<b><i>b</i>b</b></p>', + start: 'p:contents()[0]->0', + end: 'i:contents()[0]->1', + do: function () { + $btnRemoveStyles.mousedown().click(); + }, + test: { + content: '<p>aaa<span class="fa fa-heart"></span>bb<b>b</b></p>', + start: 'p:contents()[0]->0', + end: 'p:contents()[2]->2', + }, + }, + /* COMPLEX */ + { + name: "COMPLEX Click BOLD: italic -> italic bold (partial selection)", + content: '<p>dom not to edit</p><p><i>dom to edit</i></p>', + start: 'i:contents()[0]->1', + end: 'i:contents()[0]->5', + do: function () { + $btnBold.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><i>d<b>om t</b>o edit</i></p>', + start: 'b:contents()[0]->0', + end: 'b:contents()[0]->4', + }, + }, + { + name: "COMPLEX Click BOLD then 'a': italic bold -> italic (across paragraphs)", + content: '<p><b><i>dom to edit</i></b></p><p><i><b>dom to edit</b></i></p>', + start: 'i:contents()[0]->1', + end: 'b:eq(1):contents()[0]->5', + do: function () { + $btnBold.mousedown().click(); + weTestUtils.keydown('a', $editable, { + firstDeselect: true, + }); + }, + test: { + content: '<p><b><i>d</i></b><i>om to edit</i></p><p><i>dom ta<b>o edit</b></i></p>', + }, + }, + { + name: "COMPLEX Click BOLD then 'a': bold italic -> italic (no selection)", + content: '<p><b><i>dom to edit</i></b></p>', + start: 'i:contents()[0]->4', + do: function () { + $btnBold.mousedown().click(); + weTestUtils.keydown('a', $editable, { + firstDeselect: true, + }); + }, + test: { + content: '<p><b><i>dom </i></b><i>a</i><b><i>to edit</i></b></p>', + }, + }, + { + name: "COMPLEX Click BOLD then 'a': underlined italic -> underlined italic bold (across paragraphs)", + content: '<p><u><i>dom to edit</i></u></p><p><i><u>dom to edit</u></i></p>', + start: 'i:contents()[0]->1', + end: 'u:eq(1):contents()[0]->5', + do: function () { + $btnBold.mousedown().click(); + weTestUtils.keydown('a', $editable, { + firstDeselect: true, + }); + }, + test: { + content: '<p><u><i>d<b>om to edit</b></i></u></p><p><i><u><b>dom ta</b>o edit</u></i></p>', + }, + }, + { + name: "COMPLEX Click BOLD then 'a': underlined italic -> underlined italic bold (no selection)", + content: '<p><u><i>dom to edit</i></u></p>', + start: 'i:contents()[0]->1', + do: function () { + $btnBold.mousedown().click(); + weTestUtils.keydown('a', $editable, { + firstDeselect: true, + }); + }, + test: { + content: '<p><u><i>d<b>a</b>om to edit</i></u></p>', + }, + }, + ]; + + _.each(styleTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + test.end = test.end || test.start; + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + $editable[0].normalize(); + if (test.test.start) { + test.test.end = test.test.end || test.test.start; + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + } + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('Font size', function (assert) { + var done = assert.async(); + assert.expect(10); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $dropdownFontSize = wysiwyg.$('.note-fontsize .dropdown-toggle'); + var $linksFontSize = wysiwyg.$('.note-fontsize .dropdown-menu .dropdown-item'); + + var sizeTests = [{ + name: "Click 18: default -> 18px", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $dropdownFontSize.mousedown().click(); + $linksFontSize.filter(':contains("18")').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<font style="font-size: 18px;">om t</font>o edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->4', + }, + }, + { + name: "Click DEFAULT: 18px -> default", + content: '<p>dom not to edit</p><p><font style="font-size: 18px;">dom to edit</font></p>', + start: 'font:contents()[0]->1', + end: 'font:contents()[0]->5', + do: function () { + $dropdownFontSize.mousedown().click(); + $linksFontSize.filter(':contains("Default")').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p><font style="font-size: 18px;">d</font>om t<font style="font-size: 18px;">o edit</font></p>', + start: 'p:eq(1):contents()[1]->0', + end: 'p:eq(1):contents()[1]->4', + }, + }, + { + name: "Click 18: 26px -> 18px", + content: '<p><font style="font-size: 26px;">dom to edit</font></p>', + start: 'font:contents()[0]->1', + end: 'font:contents()[0]->5', + do: function () { + $dropdownFontSize.mousedown().click(); + $linksFontSize.filter(':contains("18")').mousedown().click(); + }, + test: { + content: '<p>' + + '<font style="font-size: 26px;">d</font>' + + '<font style="font-size: 18px;">om t</font>' + + '<font style="font-size: 26px;">o edit</font></p>', + start: 'font:eq(1):contents()[0]->0', + end: 'font:eq(1):contents()[0]->4', + }, + }, + { + name: "Click 18 then 'a' (no selection): default -> 18px", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownFontSize.mousedown().click(); + $linksFontSize.filter(':contains("18")').mousedown().click(); + weTestUtils.keydown('a', $editable, { + firstDeselect: true, + }); + }, + test: { + content: '<p>dom not to edit</p><p>d<font style="font-size: 18px;">a</font>om to edit</p>', + start: 'font:contents()[0]->1', + }, + }, + { + name: "Click 18 then 'a' (no selection): 26px -> 18px", + content: '<p><font style="font-size: 26px;">dom to edit</font></p>', + start: 'font:contents()[0]->1', + do: function () { + $dropdownFontSize.mousedown().click(); + $linksFontSize.filter(':contains("18")').mousedown().click(); + weTestUtils.keydown('a', $editable, { + firstDeselect: true, + }); + }, + test: { + content: '<p>' + + '<font style="font-size: 26px;">d</font>' + + '<font style="font-size: 18px;">a</font>' + + '<font style="font-size: 26px;">om to edit</font></p>', + start: 'font:eq(1):contents()[0]->1', + }, + }, + ]; + + _.each(sizeTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('Text forecolor', function (assert) { + var done = assert.async(); + assert.expect(40); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $dropdownForeColor = wysiwyg.$('.note-color .note-fore-color .dropdown-toggle'); + var $btnsForeColor = wysiwyg.$('.note-color .note-fore-color .dropdown-menu .note-palette .note-color-btn'); + + var defColorpickerDialogInit; + testUtils.mock.patch(ColorpickerDialog, { + init: function () { + this._super.apply(this, arguments); + defColorpickerDialogInit = $.Deferred(); + this.opened(defColorpickerDialogInit.resolve.bind(defColorpickerDialogInit)); + }, + }); + + var forecolorTests = [{ + name: "Click THEME COLORS - ALPHA: default -> alpha theme color", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('.bg-alpha').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<font class="text-alpha">om t</font>o edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->4', + }, + }, + { + name: "Click THEME COLORS - BLACK 25: alpha theme color & default -> black 25", + content: '<p>dom not to edit</p><p>do<font class="text-alpha">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('.bg-black-25').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<font class="text-black-25">om t</font><font class="text-alpha">o </font>edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->4', + }, + }, + { + name: "Click COMMON COLORS - BLUE #0000FF: black 25 & default -> blue #0000FF", + content: '<p>dom not to edit</p><p>do<font class="text-black-25">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('[style="background-color:#0000FF"]').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<font style="color: rgb(0, 0, 255);">om t</font><font class="text-black-25">o </font>edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->4', + }, + }, + { + name: "Click RESET TO DEFAULT: black 25 & default -> default", + content: '<p>dom not to edit</p><p>do<font class="text-black-25">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('.note-color-reset').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom t<font class="text-black-25">o </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click CUSTOM COLORS then CUSTOM COLOR: blue #0000FF & default -> #875A7B", + async: true, + content: '<p>dom not to edit</p><p>do<font style="color: rgb(0, 0, 255);">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + var self = this; + var def = $.Deferred(); + + $dropdownForeColor.mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color').mousedown().click(); + + defColorpickerDialogInit.then(function () { + $('.modal-dialog .o_hex_input').val('#875A7B').change(); + $('.o_technical_modal .modal-footer .btn-primary:contains("Choose")').mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color-btn:last').mousedown().click(); + + assert.deepEqual(wysiwyg.getValue(), + '<p>dom not to edit</p><p>d<font style="color: rgb(135, 90, 123);">om t</font><font style="color: rgb(0, 0, 255);">o </font>edit</p>', + self.name); + var range = weTestUtils.select('font:contents()[0]->0', 'font:contents()[0]->4', $editable); + assert.deepEqual(Wysiwyg.getRange($editable[0]), range, self.name + carretTestSuffix); + def.resolve(); + }); + return def; + }, + }, + { + name: "Click CUSTOM COLORS then CUSTOM COLOR: change blue input", + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[0]->6', + do: function () { + var self = this; + var def = $.Deferred(); + + $dropdownForeColor.mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color').mousedown().click(); + + defColorpickerDialogInit.then(function () { + $('.modal-dialog .o_blue_input').val('100').change(); + + assert.deepEqual($('.modal-dialog .o_hex_input').val(), '#ff0064', self.name + ' (hex)'); + assert.deepEqual($('.modal-dialog .o_hue_input').val(), '337', self.name + ' (hue)'); + + $('.o_technical_modal .modal-footer .btn-primary:contains("Choose")').mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color-btn:last').mousedown().click(); + def.resolve(); + }); + return def; + }, + test: { + content: '<p>d<font style="color: rgb(255, 0, 100);">om to</font> edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->5', + }, + }, + { + name: "CUSTOM COLOR: change hue, saturation and lightness inputs", + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[0]->6', + do: function () { + var self = this; + var def = $.Deferred(); + + $dropdownForeColor.mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color').mousedown().click(); + + defColorpickerDialogInit.then(function () { + $('.modal-dialog .o_hue_input').val('337').change(); + $('.modal-dialog .o_saturation_input').val('50').change(); + $('.modal-dialog .o_lightness_input').val('40').change(); + + assert.deepEqual($('.modal-dialog .o_hex_input').val(), '#99335a', self.name + ' (hex)'); + assert.deepEqual($('.modal-dialog .o_green_input').val(), '51', self.name + ' (green)'); + + $('.o_technical_modal .modal-footer .btn-primary:contains("Choose")').mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color-btn:last').mousedown().click(); + def.resolve(); + }); + return def; + }, + test: { + content: '<p>d<font style="color: rgb(153, 51, 90);">om to</font> edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->5', + }, + }, + { + name: "CUSTOM COLOR: mousedown on area", + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[0]->6', + do: function () { + var self = this; + var def = $.Deferred(); + + $dropdownForeColor.mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color').mousedown().click(); + + defColorpickerDialogInit.then(function () { + var $area = $('.modal-dialog .o_color_pick_area'); + var pos = $area.offset(); + $area.trigger($.Event("mousedown", { + which: 1, + pageX: pos.left + 50, + pageY: pos.top + 50 + })); + $area.trigger('mouseup'); + + assert.deepEqual($('.modal-dialog .o_hex_input').val(), '#cfafaf', self.name + ' (hex)'); + assert.deepEqual($('.modal-dialog .o_red_input').val(), '207', self.name + ' (red)'); + assert.deepEqual($('.modal-dialog .o_green_input').val(), '175', self.name + ' (green)'); + assert.deepEqual($('.modal-dialog .o_blue_input').val(), '175', self.name + ' (blue)'); + assert.deepEqual($('.modal-dialog .o_hue_input').val(), '0', self.name + ' (hue)'); + assert.deepEqual($('.modal-dialog .o_saturation_input').val(), '25', self.name + ' (saturation)'); + assert.deepEqual($('.modal-dialog .o_lightness_input').val(), '75', self.name + ' (lightness)'); + + $('.o_technical_modal .modal-footer .btn-primary:contains("Choose")').mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color-btn:last').mousedown().click(); + def.resolve(); + }); + return def; + }, + test: { + content: '<p>d<font style="color: rgb(207, 175, 175);">om to</font> edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->5', + }, + }, + { + name: "CUSTOM COLOR: mousedow on sliders", + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[0]->6', + do: function () { + var self = this; + var def = $.Deferred(); + + $dropdownForeColor.mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color').mousedown().click(); + + defColorpickerDialogInit.then(function () { + var $slider1 = $('.modal-dialog .o_slider_pointer'); + var pos1 = $slider1.offset(); + $slider1.trigger($.Event("mousedown", { + which: 1, + pageX: pos1.left, + pageY: pos1.top + 50 + })); + $slider1.trigger('mouseup'); + + assert.deepEqual($('.modal-dialog .o_hex_input').val(), '#83ff00', self.name + ' (hex)'); + + var $slider2 = $('.modal-dialog .o_opacity_slider'); + var pos2 = $slider2.offset(); + $slider2.trigger($.Event("mousedown", { + which: 1, + pageX: pos2.left, + pageY: pos2.top + 80 + })); + $slider2.trigger('mouseup'); + + assert.deepEqual($('.modal-dialog .o_hue_input').val(), '89', self.name + ' (hue)'); + assert.deepEqual($('.modal-dialog .o_opacity_input').val(), '60', self.name + ' (opacity)'); + + $('.o_technical_modal .modal-footer .btn-primary:contains("Choose")').mousedown().click(); + wysiwyg.$('.note-color .note-fore-color .note-custom-color-btn:last').mousedown().click(); + def.resolve(); + }); + return def; + }, + test: { + content: '<p>d<font style="color: rgba(131, 255, 0, 0.6);">om to</font> edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->5', + }, + }, + { + name: "Apply a color on a fontawesome", + content: '<p>dom <i class="fa fa-glass"/>not to edit</p>', + start: 'i->0', + do: function () { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('[style="background-color:#0000FF"]').mousedown().click(); + }, + test: { + content: '<p>dom <i class="fa fa-glass" style="color: rgb(0, 0, 255);"></i>not to edit</p>', + start: 'p->2', + }, + }, + { + name: "Apply a color on a font with text", + content: '<p>dom <i class="fa fa-glass"/>not to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[2]->6', + do: function () { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('[style="background-color:#0000FF"]').mousedown().click(); + }, + test: { + content: '<p>d<font style="color: rgb(0, 0, 255);">om </font><i class="fa fa-glass" style="color: rgb(0, 0, 255);"></i><font style="color: rgb(0, 0, 255);">not to</font> edit</p>', + start: 'font:eq(0):contents()[0]->0', + end: 'font:eq(1):contents()[0]->6', + }, + }, + { + name: "Apply color, then 'a' (no selection)", + content: '<p>dom not to edit</p>', + start: 'p:contents()[0]->1', + do: function () { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('[style="background-color:#0000FF"]').mousedown().click(); + weTestUtils.keydown('a', $editable, { + firstDeselect: true, + }); + }, + test: { + content: '<p>d<font style="color: rgb(0, 0, 255);">a</font>om not to edit</p>', + start: 'font:contents()[0]->1', + }, + }, + { + name: "Apply color on two ranges with the same color", + content: '<p>do<br><span class="toto"> </span>m not to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[3]->4', + do: function ($editable) { + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('[style="background-color:#0000FF"]').mousedown().click(); + + var range = weTestUtils.select('p:contents()[5]->3', 'p:contents()[5]->6', $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + + $dropdownForeColor.mousedown().click(); + $btnsForeColor.filter('[style="background-color:#0000FF"]').mousedown().click(); + }, + test: { + content: '<p>d<font style="color: rgb(0, 0, 255);">o</font><br><span class="toto"> </span><font style="color: rgb(0, 0, 255);">m no</font>t t<font style=\"color: rgb(0, 0, 255);\">o e</font>dit</p>', + start: 'font:eq(2):contents()[0]->0', + end: 'font:eq(2):contents()[0]->3', + }, + }, + ]; + + var def = $.when(); + _.each(forecolorTests, function (test) { + def = def.then(function () { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + return $.when(test.do($editable)).then(function () { + if (!test.async) { + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + } + }); + }); + }); + def.then(function () { + testUtils.mock.unpatch(ColorpickerDialog); + wysiwyg.destroy(); + done(); + }); + }); +}); + +QUnit.test('Text bgcolor', function (assert) { + var done = assert.async(); + assert.expect(10); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + var testName = ""; + var carretTestSuffix = " (carret position)"; + + var $dropdownBgColor = wysiwyg.$('.note-color .note-bg-color .dropdown-toggle'); + var $btnsBgColor = wysiwyg.$('.note-color .note-bg-color .dropdown-menu .note-palette .note-color-btn'); + + var defColorpickerDialogInit; + testUtils.mock.patch(ColorpickerDialog, { + init: function () { + this._super.apply(this, arguments); + defColorpickerDialogInit = $.Deferred(); + this.opened(defColorpickerDialogInit.resolve.bind(defColorpickerDialogInit)); + }, + }); + + var bgcolorTests = [{ + name: "Click THEME COLORS - ALPHA: default -> alpha theme color", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $dropdownBgColor.mousedown().click(); + $btnsBgColor.filter('.bg-alpha').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<font class="bg-alpha">om t</font>o edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->4', + }, + }, + { + name: "Click THEME COLORS - BLACK 25: alpha theme color & default -> black 25", + content: '<p>dom not to edit</p><p>do<font class="bg-alpha">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + $dropdownBgColor.mousedown().click(); + $btnsBgColor.filter('.bg-black-25').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<font class="bg-black-25">om t</font><font class="bg-alpha">o </font>edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->4', + }, + }, + { + name: "Click COMMON COLORS - BLUE #0000FF: black 25 & default -> blue #0000FF", + content: '<p>dom not to edit</p><p>do<font class="bg-black-25">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + $dropdownBgColor.mousedown().click(); + $btnsBgColor.filter('[style="background-color:#0000FF"]').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>d<font style="background-color: rgb(0, 0, 255);">om t</font><font class="bg-black-25">o </font>edit</p>', + start: 'font:contents()[0]->0', + end: 'font:contents()[0]->4', + }, + }, + { + name: "Click RESET TO DEFAULT: black 25 & default -> default", + content: '<p>dom not to edit</p><p>do<font class="bg-black-25">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + $dropdownBgColor.mousedown().click(); + $btnsBgColor.filter('.note-color-reset').mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom t<font class="bg-black-25">o </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click CUSTOM COLORS then CUSTOM COLOR: blue #0000FF & default -> #875A7B", + async: true, + content: '<p>dom not to edit</p><p>do<font style="background-color: rgb(0, 0, 255);">m to </font>edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'font:contents()[0]->3', + do: function () { + var def = $.Deferred(); + testName = "Click CUSTOM COLORS then CUSTOM COLOR: blue #0000FF & default -> #875A7B"; + + $dropdownBgColor.mousedown().click(); + wysiwyg.$('.note-color .note-bg-color .note-custom-color').mousedown().click(); + + defColorpickerDialogInit.then(function () { + $('.modal-dialog .o_hex_input').val('#875A7B').change(); + $('.o_technical_modal .modal-footer .btn-primary:contains("Choose")').mousedown().click(); + wysiwyg.$('.note-color .note-bg-color .note-custom-color-btn:last').mousedown().click(); + + assert.deepEqual(wysiwyg.getValue(), + '<p>dom not to edit</p><p>d<font style="background-color: rgb(135, 90, 123);">om t</font><font style="background-color: rgb(0, 0, 255);">o </font>edit</p>', + testName); + var range = weTestUtils.select('font:contents()[0]->0', + 'font:contents()[0]->4', + $editable); + assert.deepEqual(Wysiwyg.getRange($editable[0]), range, testName + carretTestSuffix); + def.resolve(); + }); + return def; + }, + }, + ]; + + var def = $.when(); + _.each(bgcolorTests, function (test) { + def = def.then(function () { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + return $.when(test.do()).then(function () { + if (!test.async) { + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + } + }); + }); + }); + def.then(function () { + testUtils.mock.unpatch(ColorpickerDialog); + wysiwyg.destroy(); + done(); + }); + }); +}); + +QUnit.test('Unordered list', function (assert) { + var done = assert.async(); + assert.expect(10); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $btnUL = wysiwyg.$('.note-para .note-icon-unorderedlist'); + + var ulTests = [{ + name: "Click UL: p -> ul", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $btnUL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><ul><li><p>dom to edit</p></li></ul>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click UL: p -> ul (across paragraphs)", + content: '<p>dom not to edit</p><p>dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $btnUL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><ul><li><p>dom to edit</p></li><li><p>dom to edit</p></li></ul>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + { + name: "Click UL: ul -> p", + content: '<p>dom not to edit</p><ul><li><p>dom to edit</p></li></ul>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $btnUL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click UL: p -> ul (across li's)", + content: '<p>dom not to edit</p><ul><li><p>dom to edit</p></li><li><p>dom to edit</p></li></ul>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $btnUL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + { + name: "Click UL: ul -> p (from second li)", + content: '<p>dom not to edit</p><ul><li><p>xxx</p></li><li><p>dom to edit</p></li></ul>', + start: 'li:eq(1) p:contents()[0]->1', + end: 'li:eq(1) p:contents()[0]->5', + do: function () { + $btnUL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><ul><li><p>xxx</p></li></ul><p>dom to edit</p>', + start: 'p:eq(2):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + ]; + + _.each(ulTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('Ordered list', function (assert) { + var done = assert.async(); + assert.expect(32); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $btnOL = wysiwyg.$('.note-para .note-icon-orderedlist'); + + var olTests = [{ + name: "Click OL: p -> ol", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><ol><li><p>dom to edit</p></li></ol>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click OL: p -> ol (across paragraphs)", + content: '<p>dom not to edit</p><p>dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><ol><li><p>dom to edit</p></li><li><p>dom to edit</p></li></ol>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + { + name: "Click OL: ol -> p", + content: '<p>dom not to edit</p><ol><li><p>dom to edit</p></li></ol>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click OL: p -> ol (across li's)", + content: '<p>dom not to edit</p><ol><li><p>dom to edit</p></li><li><p>dom to edit</p></li></ol>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + { + name: "Click OL: ol -> p (from second li) (2)", + content: '<p>dom not to edit</p><ol><li><p>dom to edit</p></li><li><p>dom to edit</p></li></ol>', + start: 'li:eq(0) p:contents()[0]->1', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p><ol><li><p>dom to edit</p></li></ol>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click OL: ol -> p (from second li)", + content: '<p>dom not to edit</p><ol><li><p>dom to edit</p></li><li><p>dom to edit</p></li></ol>', + start: 'li:eq(0) p:contents()[0]->1', + end: 'li:eq(0) p:contents()[0]->5', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p><ol><li><p>dom to edit</p></li></ol>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click OL + Click OL: ul ul -> ul ol -> ul", + content: '<p>a</p>' + + '<ul>' + + '<li><p>b</p></li>' + + '<ul>' + + '<li><p>c</p></li>' + + '<li><p>d</p></li>' + + '</ul>' + + '<li><p>e</p></li>' + + '</ul>', + start: 'ul ul li:first:contents()[0]->1', + do: function () { + $btnOL.mousedown().click(); + $btnOL.mousedown().click(); + }, + test: { + content: '<p>a</p>' + + '<ul>' + + '<li><p>b</p></li>' + + '<li><p>c</p></li>' + + '<ul>' + + '<li><p>d</p></li>' + + '</ul>' + + '<li><p>e</p></li>' + + '</ul>', + start: 'ul li:eq(1) p:contents()[0]->1', + }, + }, + { + name: "Click OL in empty table cell in div", + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'tr:eq(0) td:eq(1)->1', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><ol><li><p><br></p></li></ol></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'tr:eq(0) td:eq(1) br->0', + }, + }, + { + name: "Click OL in empty table cell in div (2)", + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'tr:eq(0) td:eq(1) br->0', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><ol><li><p><br></p></li></ol></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'tr:eq(0) td:eq(1) br->0', + }, + }, + { + name: "Click OL in empty table cell in div (3)", + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'tr:eq(0) td:eq(1)->0', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><ol><li><p><br></p></li></ol></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'tr:eq(0) td:eq(1) br->0', + }, + }, + { + name: "Click OL in table cell in div", + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td>aaa</td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'tr:eq(0) td:eq(1):contents(0)->1', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><ol><li><p>aaa</p></li></ol></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'td li p:contents(0)->1', + }, + }, + { + name: "Click OL on image in table cell in div", + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><img data-src="/web_editor/static/src/img/transparent.png"></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'img->0', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<div>' + + '<p>a</p>' + + '<table>' + + '<tbody>' + + '<tr>' + + '<td><br></td>' + + '<td><ol><li><img data-src="/web_editor/static/src/img/transparent.png"></li></ol></td>' + + '<td><br></td>' + + '</tr>' + + '<tr>' + + '<td><br></td>' + + '<td><br></td>' + + '<td><br></td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '</div>', + start: 'td li->1', + }, + }, + { + name: "Click OL with selected LI in OL", + content: '<p>x</p>' + + '<ol>' + + '<li><p>aaa</p></li>' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ol>' + + '<p>y</p>', + start: 'p:eq(2):contents(0)->1', + end: 'p:eq(3):contents(0)->1', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>x</p>' + + '<ol>' + + '<li><p>aaa</p></li>' + + '</ol>' + + '<p>bbb</p>' + + '<p>ccc</p>' + + '<ol>' + + '<li><p>ddd</p></li>' + + '</ol>' + + '<p>y</p>', + start: 'p:eq(2):contents(0)->1', + end: 'p:eq(3):contents(0)->1', + }, + }, + { + name: "Click OL with selected LI in UL", + content: '<p>x</p>' + + '<ul>' + + '<li><p>aaa</p></li>' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '<p>y</p>', + start: 'p:eq(2):contents(0)->1', + end: 'p:eq(3):contents(0)->1', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<p>x</p>' + + '<ul>' + + '<li><p>aaa</p></li>' + + '</ul>' + + '<ol>' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '</ol>' + + '<ul>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '<p>y</p>', + start: 'p:eq(2):contents(0)->1', + end: 'p:eq(3):contents(0)->1', + }, + }, + { + name: "Click OL after ENTER in p > b", + content: "<p><b>dom to edit</b></p>", + start: 'b:contents(0)->11', + do: function () { + weTestUtils.keydown(13, $editable); // ENTER + $btnOL.mousedown().click(); + weTestUtils.keydown('a', $editable); // ENTER + }, + test: { + content: "<p><b>dom to edit</b></p><ol><li><p><b>a</b></p></li></ol>", + start: 'b:eq(1):contents(0)->1', + }, + }, + { + name: "Click OL in font in H1 (with link) in div", + content: '<div><h1><font style="font-size: 62px;">table of contents <a href="p23">p23</a> (cfr: 34)</font></h1></div>', + start: 'font:contents(0)->11', + do: function () { + $btnOL.mousedown().click(); + }, + test: { + content: '<div><ol><li><h1><font style="font-size: 62px;">table of contents <a href="p23">p23</a> (cfr: 34)</font></h1></li></ol></div>', + start: 'font:contents(0)->11', + }, + }, + ]; + + _.each(olTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + $(range.sc).mousedown(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + if (wysiwyg.getValue() === test.test.content) { + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + } else { + assert.notOk(true, testName + ' (Wrong DOM)'); + } + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('Align', function (assert) { + var done = assert.async(); + assert.expect(20); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $dropdownPara = wysiwyg.$('.note-para .dropdown-toggle'); + var $btnAlignLeft = wysiwyg.$('.note-align .note-icon-align-left'); + var $btnAlignCenter = wysiwyg.$('.note-align .note-icon-align-center'); + var $btnAlignRight = wysiwyg.$('.note-align .note-icon-align-right'); + var $btnAlignJustify = wysiwyg.$('.note-align .note-icon-align-justify'); + + var alignTests = [ + /* ALIGN LEFT */ + { + name: "Click ALIGN LEFT: p -> p align left (does nothing)", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignLeft.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click ALIGN LEFT: p (parent align right) -> p align left (does nothing)", + content: '<div style="text-align: right;"><p>dom not to edit</p><p>dom to edit</p></div>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignLeft.mousedown().click(); + }, + test: { + content: '<div style="text-align: right;"><p>dom not to edit</p><p style="text-align: left;">dom to edit</p></div>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click ALIGN LEFT: p (parent align left) -> p align left (does nothing)", + content: '<div style="text-align: left;"><p>dom not to edit</p><p>dom to edit</p></div>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignLeft.mousedown().click(); + }, + test: { + content: '<div style="text-align: left;"><p>dom not to edit</p><p>dom to edit</p></div>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(1):contents()[0]->5', + }, + }, + { + name: "Click ALIGN LEFT: p align justify & default -> p align right (across paragraphs)", + content: '<p>dom not to edit</p><p style="text-align: justify;">dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignRight.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p style="text-align: right;">dom to edit</p><p style="text-align: right;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + /* ALIGN CENTER */ + { + name: "Click ALIGN CENTER: p -> p align center", + content: '<p>dom not to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignCenter.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p style="text-align: center;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click ALIGN CENTER: p align left & default -> p align center (across paragraphs)", + content: '<p>dom not to edit</p><p style="text-align: left;">dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignCenter.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p style="text-align: center;">dom to edit</p><p style="text-align: center;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + /* ALIGN RIGHT */ + { + name: "Click ALIGN RIGHT: p align center -> p align right", + content: '<p>dom not to edit</p><p style="text-align: center;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignRight.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p style="text-align: right;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click ALIGN RIGHT: p align center & default -> p align right (across paragraphs)", + content: '<p>dom not to edit</p><p style="text-align: center;">dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignRight.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p style="text-align: right;">dom to edit</p><p style="text-align: right;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + /* ALIGN JUSTIFY */ + { + name: "Click ALIGN JUSTIFY: p align right -> p align justify", + content: '<p>dom not to edit</p><p style="text-align: right;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignJustify.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p style="text-align: justify;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click ALIGN JUSTIFY: p align right & default -> p align justify (across paragraphs)", + content: '<p>dom not to edit</p><p style="text-align: right;">dom to edit</p><p>dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + do: function () { + $dropdownPara.mousedown().click(); + $btnAlignJustify.mousedown().click(); + }, + test: { + content: '<p>dom not to edit</p><p style="text-align: justify;">dom to edit</p><p style="text-align: justify;">dom to edit</p>', + start: 'p:eq(1):contents()[0]->1', + end: 'p:eq(2):contents()[0]->5', + }, + }, + ]; + + _.each(alignTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('Indent/outdent', function (assert) { + var done = assert.async(); + assert.expect(18); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $dropdownPara = wysiwyg.$('.note-para .dropdown-toggle'); + var $btnIndent = wysiwyg.$('.note-list .note-btn:eq(1)'); + var $btnOutdent = wysiwyg.$('.note-list .note-btn:first'); + + var indentTests = [ + /* INDENT */ + { + name: "Click INDENT: p -> indented p", + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnIndent.mousedown().click(); + }, + test: { + content: '<p style="margin-left: 1.5em;">dom to edit</p>', + start: 'p:contents()[0]->1', + }, + }, + { + name: "Click INDENT: li -> indented li", + content: '<ul><li><p>dom</p></li><li><p>to edit</p></li></ul>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnIndent.mousedown().click(); + }, + test: { + content: '<ul><li><p>dom</p></li><li class="o_indent"><ul><li><p>to edit</p></li></ul></li></ul>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + /* OUTDENT */ + { + name: "Click OUTDENT: indented p -> p", + content: '<p style="margin-left: 1.5em;">dom to edit</p>', + start: 'p:contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnOutdent.mousedown().click(); + }, + test: { + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + }, + }, + { + name: "Click OUTDENT: indented li -> li", + content: '<ul><li><p>dom</p></li><li><ul><li><p>to edit</p></li></ul></li></ul>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnOutdent.mousedown().click(); + }, + test: { + content: '<ul><li><p>dom</p></li><li><p>to edit</p></li></ul>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click OUTDENT: indented li -> li (2)", + content: '<ul><li><p>dom</p></li><ul><li><p>to edit</p></li></ul></ul>', + start: 'p:eq(1):contents()[0]->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnOutdent.mousedown().click(); + }, + test: { + content: '<ul><li><p>dom</p></li><li><p>to edit</p></li></ul>', + start: 'p:eq(1):contents()[0]->1', + }, + }, + { + name: "Click OUTDENT on LI in OL in OL", + content: '<p>x</p>' + + '<ol>' + + '<li><p>aaa</p></li>' + + '<li><ol>' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ol></li>' + + '<li><p>eee</p></li>' + + '</ol>' + + '<p>y</p>', + start: 'ol ol p:eq(1):contents(0)->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnOutdent.mousedown().click(); + }, + test: { + content: '<p>x</p>' + + '<ol>' + + '<li><p>aaa</p></li>' + + '<li><ol>' + + '<li><p>bbb</p></li>' + + '</ol></li>' + + '<li><p>ccc</p></li>' + + '<li><ol>' + + '<li><p>ddd</p></li>' + + '</ol></li>' + + '<li><p>eee</p></li>' + + '</ol>' + + '<p>y</p>', + start: 'ol:first > li:eq(2) p:contents(0)->1', + }, + }, + { + name: "Click OUTDENT on LI in OL in OL (2)", + content: '<p>x</p>' + + '<ol>' + + '<li><p>aaa</p></li>' + + '<ol>' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ol>' + + '<li><p>eee</p></li>' + + '</ol>' + + '<p>y</p>', + start: 'ol ol p:eq(1):contents(0)->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnOutdent.mousedown().click(); + }, + test: { + content: '<p>x</p>' + + '<ol>' + + '<li><p>aaa</p></li>' + + '<ol>' + + '<li><p>bbb</p></li>' + + '</ol>' + + '<li><p>ccc</p></li>' + + '<ol>' + + '<li><p>ddd</p></li>' + + '</ol>' + + '<li><p>eee</p></li>' + + '</ol>' + + '<p>y</p>', + start: 'li:eq(2) p:contents(0)->1', + }, + }, + { + name: "Click OUTDENT on LI in OL", + content: + '<ol>' + + '<li><p>aaa</p></li>' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '</ol>', + start: 'p:eq(1):contents(0)->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnOutdent.mousedown().click(); + }, + test: { + content: + '<ol>' + + '<li><p>aaa</p></li>' + + '</ol>' + + '<p>bbb</p>' + + '<ol>' + + '<li><p>ccc</p></li>' + + '</ol>', + start: 'p:eq(1):contents(0)->1', + }, + }, + { + name: "Click OUTDENT on P with indent in a LI (must outdent the p)", + content: + '<ul>' + + '<li>' + + '<ul>' + + '<li>' + + '<p style="margin-left: 1.5em;">dom</p>' + + '</li>' + + '</ul>' + + '</li>' + + '</ul>', + start: 'p:contents(0)->1', + do: function () { + $dropdownPara.mousedown().click(); + $btnOutdent.mousedown().click(); + }, + test: { + content: + '<ul>' + + '<li>' + + '<ul>' + + '<li>' + + '<p>dom</p>' + + '</li>' + + '</ul>' + + '</li>' + + '</ul>', + start: 'p:contents(0)->1', + }, + }, + ]; + + _.each(indentTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + assert.deepEqual(Wysiwyg.getRange($editable[0]), weTestUtils.select(test.test.start, test.test.end, $editable), testName + carretTestSuffix); + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('checklist', function (assert) { + var done = assert.async(); + assert.expect(10); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var checklistTests = [ + { + name: "check checkbox in checklist with children", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('li:first'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "check checkbox in checklist with children (2)", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('li:first'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "uncheck checkbox in checklist with children", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('li:first'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "uncheck checkbox in checklist with children (2)", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('li:first'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "uncheck checkbox in checklist in checklist", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('ul ul li:eq(1)'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "uncheck checkbox in checklist in checklist (2)", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('ul ul li:eq(1)'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "check checkbox in checklist in checklist", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('ul ul li:eq(1)'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "check checkbox in checklist in checklist (2)", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('ul ul li:eq(1)'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "check checkbox in checklist in checklist (full)", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('ul ul li:eq(1)'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '</li>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + { + name: "check checkbox in checklist in checklist (full) (2)", + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + do: function () { + var $li = $editable.find('ul ul li:eq(1)'); + var pos = $li.offset(); + $li.trigger($.Event('mousedown', { + offsetX: -10, + })); + }, + test: { + content: '<p>x</p>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>aaa</p></li>' + + '<ul class="o_checklist">' + + '<li class="o_checked"><p>bbb</p></li>' + + '<li class="o_checked"><p>ccc</p></li>' + + '<li class="o_checked"><p>ddd</p></li>' + + '</ul>' + + '<li><p>eee</p></li>' + + '</ul>' + + '<p>y</p>', + }, + }, + ]; + + _.each(checklistTests, function (test) { + testName = test.name; + wysiwyg.setValue(test.content); + test.do(); + assert.deepEqual(wysiwyg.getValue(), test.test.content, testName); + }); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('Link', function (assert) { + var done = assert.async(); + assert.expect(19); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + + }, + }).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var $btnLink = wysiwyg.$('.note-insert .note-icon-link'); + + var defLinkDialogInit; + testUtils.mock.patch(LinkDialog, { + init: function () { + this._super.apply(this, arguments); + defLinkDialogInit = $.Deferred(); + this.opened(defLinkDialogInit.resolve.bind(defLinkDialogInit)); + }, + save: function () { + defLinkDialogInit = null; + return this._super.apply(this, arguments); + }, + }); + var _clickLink = function (callbackInit, test) { + $btnLink.mousedown().click(); + defLinkDialogInit.then(callbackInit).then(function () { + $('.modal-dialog .btn-primary:contains("Save")').mousedown().click(); + if (test.check) { + test.check(); + } + if (test.content) { + assert.deepEqual(wysiwyg.getValue(), test.content, testName); + } + if (test.start) { + var range = weTestUtils.select(test.start, test.end, $editable); + assert.deepEqual(Wysiwyg.getRange($editable[0]), range, testName + carretTestSuffix); + } + }); + return defLinkDialogInit; + }; + + var linkTests = [{ + name: "Click LINK: p -> a in p (w/ URL)", + async: true, + content: '<p>dom to edit</p>', + start: "p:contents()[0]->1", + end: "p:contents()[0]->5", + do: function () { + assert.strictEqual($('.modal-dialog #o_link_dialog_label_input').val(), 'om t', testName + ' (label)'); + $('.modal-dialog #o_link_dialog_url_input').val('#'); + }, + test: { + content: '<p>d<a href="#">om t</a>o edit</p>', + start: 'a:contents()[0]->0', + end: 'a:contents()[0]->4', + }, + }, + { + name: "Click LINK: p -> a in p (w/ URL) (no selection)", + async: true, + content: '<p>do edit</p>', + start: 'p:contents()[0]->1', + do: function () { + $('.modal-dialog #o_link_dialog_label_input').val('om t'); + $('.modal-dialog #o_link_dialog_url_input').val('#'); + }, + test: { + content: '<p>d<a href="#">om t</a>o edit</p>', + start: 'p->2', // link not selected, the user can continue to write + end: 'p->2', + }, + }, + { + name: "Click LINK: a.btn in div -> a.btn.btn-outline-alpha in div (edit) (no selection)", + content: '<div><a href="#" class="btn btn-outline-alpha btn-lg">dom to edit</a></div>', + start: 'a:contents()[0]->5', + do: function () { + assert.strictEqual($('.modal-dialog #o_link_dialog_label_input').val(), 'dom to edit', testName + ' (label)'); + $('.modal-dialog #o_link_dialog_url_input').val('#newlink'); + }, + test: { + content: '<div><a href="#newlink" class="btn btn-outline-alpha btn-lg">dom to edit</a></div>', + start: 'a->0', + end: 'a->1', + }, + }, + { + name: "Click LINK: p -> a in p (w/ Email)", + async: true, + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[0]->5', + do: function () { + $('.modal-dialog #o_link_dialog_url_input').val('john.coltrane@example.com'); + }, + test: { + content: '<p>d<a href="mailto:john.coltrane@example.com">om t</a>o edit</p>', + start: 'a:contents()[0]->0', + end: 'a:contents()[0]->4', + }, + }, + { + name: "Click LINK: p -> a in p (w/ URL & Size Large)", + async: true, + content: '<p>dom to edit</p>', + start: 'p:contents()[0]->1', + end: 'p:contents()[0]->5', + do: function () { + $('.modal-dialog #o_link_dialog_url_input').val('#'); + $('.modal-dialog [name="link_style_size"]').val("lg"); + }, + test: { + content: '<p>d<a href="#" class="btn-lg">om t</a>o edit</p>', + start: 'a:contents()[0]->0', + end: 'a:contents()[0]->4', + }, + }, + { + name: "Click LINK: a in p -> a.btn-outline-alpha in p with alpha color and target=\"_blank\"", + async: true, + content: '<p><a href="#">dom to edit</a></p>', + start: 'a:contents()[0]->1', + do: function () { + $('.modal-dialog #o_link_dialog_url_input').val('#'); + $('.modal-dialog [name="link_style_shape"]').val("outline"); + $('.modal-dialog .o_link_dialog_color .o_link_dialog_color_item.btn-alpha').mousedown().click(); + $('.modal-dialog .o_switch [name="is_new_window"]').mousedown().click(); + }, + test: { + content: '<p><a href="#" target="_blank" class="btn btn-outline-alpha">dom to edit</a></p>', + start: 'a->0', + end: 'a->1', + }, + }, + /* POPOVER */ + { + name: "Click LINK in popover after adding link in p", + async: true, + content: '<p>dom to edit</p>', + start: "p:contents()[0]->1", + end: "p:contents()[0]->5", + do: function () { + $('.modal-dialog #o_link_dialog_url_input').val('/link'); + }, + test: { + check: function () { + $('.note-link-popover .note-btn .note-icon-link').mousedown().click(); + defLinkDialogInit.then(function () { + assert.strictEqual($('.modal-dialog #o_link_dialog_label_input').val(), 'om t', testName + ' (label)'); + assert.strictEqual($('.modal-dialog #o_link_dialog_url_input').val(), '/link', testName + ' (url)'); + $('.modal-dialog #o_link_dialog_url_input').val('/newlink'); + $('.modal-dialog .modal-footer .btn.btn-primary:contains("Save")').mousedown().click(); + assert.deepEqual(wysiwyg.getValue(), '<p>d<a href="/newlink">om t</a>o edit</p>', testName); + }); + }, + }, + }, + { + name: "Click UNLINK in popover after adding link in p", + async: true, + content: '<p>dom to edit</p>', + start: "p:contents()[0]->1", + end: "p:contents()[0]->5", + do: function () { + $('.modal-dialog #o_link_dialog_url_input').val('/link'); + }, + test: { + content: '<p>dom to edit</p>', + check: function () { + $('.note-link-popover .note-btn .note-icon-chain-broken').mousedown().click(); + var range = weTestUtils.select('p:contents()[0]->1', 'p:contents()[0]->5', $editable); + assert.deepEqual(Wysiwyg.getRange($editable[0]), range, testName + carretTestSuffix); + }, + }, + }, + ]; + + var def = $.when(); + _.each(linkTests, function (test) { + def = def.then(function () { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + return _clickLink(test.do, test.test); + }); + }); + def.then(function () { + testUtils.mock.unpatch(LinkDialog); + wysiwyg.destroy(); + done(); + }); + }); +}); + +QUnit.test('Table', function (assert) { + var done = assert.async(); + assert.expect(15); + + function createTable(wysiwyg) { + wysiwyg.$('.note-table button:first').mousedown().click(); + + var $grid = wysiwyg.$('.note-table .note-dimension-picker-mousecatcher'); + var pos = $grid.offset(); + $grid.trigger($.Event('mousemove', { + pageX: pos.left + 40, + pageY: pos.top + 40, + })); + $grid.mousedown().click(); + } + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: {}, + }).then(function (wysiwyg) { + var $editable = wysiwyg.getEditable(); + + // create a table in an empty dom + + wysiwyg.setValue('<p><br></p>'); + var range = weTestUtils.select('p->1', 'p->1', $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + + createTable(wysiwyg); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p><br></p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p><br></p>', + "should create a table in an empty dom (p > br)"); + + // create a table at start of p with content + + wysiwyg.setValue('<p>dom to edit</p>'); + range = weTestUtils.select('p:contents()[0]->0', 'p:contents()[0]->0', $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + + createTable(wysiwyg); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p><br></p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p>dom to edit</p>', + "should create a table at start of single p with content"); + + // create a table at end of p with content + + wysiwyg.setValue('<p>dom to edit</p>'); + range = weTestUtils.select('p:contents()[0]->11', 'p:contents()[0]->11', $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + + createTable(wysiwyg); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom to edit</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p><br></p>', + "should create a table at end of single p with content"); + + // create a table within p + + wysiwyg.setValue('<p>dom to edit</p>'); + range = weTestUtils.select('p:contents()[0]->5', 'p:contents()[0]->5', $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + + createTable(wysiwyg); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should create a table"); + + // remove a table + + range = weTestUtils.select('td:first->0', 'td:first->0', $editable); + $(range.sc).mousedown(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + + var $trash = $('.note-table-popover:visible button:has(.note-icon-trash)'); + assert.strictEqual($trash.size(), 1, "should display the table popover"); + + $trash.mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom to edit</p>', + "should remove the table"); + + // re create a table and table in table + + createTable(wysiwyg); + createTable(wysiwyg); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should create a table inside the table"); + + // remove inner table + + range = weTestUtils.select('td td:eq(1)->0', 'td td:eq(1)->0', $editable); + $(range.sc).mousedown(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + + $trash = $('.note-table-popover:visible button:has(.note-icon-trash)'); + assert.strictEqual($trash.size(), 1, "should display the table popover"); + $trash.mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should remove the inner table"); + + // use popover to change row and line + + wysiwyg.setValue('<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p>0-0</p></td><td><p>0-1</p></td><td><p>0-2</p></td></tr>' + + '<tr><td><p>1-0</p></td><td><p>1-1</p></td><td><p>1-2</p></td></tr>' + + '<tr><td><p>2-0</p></td><td><p>2-1</p></td><td><p>2-2</p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>'); + + // remove a row + + range = weTestUtils.select('td:eq(1)->0', 'td:eq(1)->0', $editable); + $(range.sc).mousedown(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + $('.note-table-popover:visible button:has(.note-icon-col-remove)').mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p>0-0</p></td><td><p>0-2</p></td></tr>' + + '<tr><td><p>1-0</p></td><td><p>1-2</p></td></tr>' + + '<tr><td><p>2-0</p></td><td><p>2-2</p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should remove a row"); + + // remove a line + + range = weTestUtils.select('tr:eq(1) td:eq(1)->0', 'tr:eq(1) td:eq(1)->0', $editable); + $(range.sc).mousedown(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + $('.note-table-popover:visible button:has(.note-icon-row-remove)').mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p>0-0</p></td><td><p>0-2</p></td></tr>' + + '<tr><td><p>2-0</p></td><td><p>2-2</p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should remove a line"); + + // add a row after + + range = weTestUtils.select('tr:eq(1) td:first->0', 'tr:eq(1) td:first->0', $editable); + $(range.sc).mousedown(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + $('.note-table-popover:visible button:has(.note-icon-col-after)').mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p>0-0</p></td><td><p><br></p></td><td><p>0-2</p></td></tr>' + + '<tr><td><p>2-0</p></td><td><p><br></p></td><td><p>2-2</p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should add a row after"); + + // add a row before + + $(range.sc).mousedown(); + $('.note-table-popover:visible button:has(.note-icon-col-before)').mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p>0-0</p></td><td><p><br></p></td><td><p>0-2</p></td></tr>' + + '<tr><td><p><br></p></td><td><p>2-0</p></td><td><p><br></p></td><td><p>2-2</p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should add a row before"); + + // add a line after + + $(range.sc).mousedown(); + $('.note-table-popover:visible button:has(.note-icon-row-below)').mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p>0-0</p></td><td><p><br></p></td><td><p>0-2</p></td></tr>' + + '<tr><td><p><br></p></td><td><p>2-0</p></td><td><p><br></p></td><td><p>2-2</p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should add a line after"); + + // add a line before + + $(range.sc).mousedown(); + $('.note-table-popover:visible button:has(.note-icon-row-above)').mousedown().click(); + + assert.strictEqual($editable.html().replace(/\s+/g, ' '), + '<p>dom t</p>' + + '<table class=\"table table-bordered\"><tbody>' + + '<tr><td><p><br></p></td><td><p>0-0</p></td><td><p><br></p></td><td><p>0-2</p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '<tr><td><p><br></p></td><td><p>2-0</p></td><td><p><br></p></td><td><p>2-2</p></td></tr>' + + '<tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr>' + + '</tbody></table>' + + '<p>o edit</p>', + "should add a line before"); + + wysiwyg.destroy(); + done(); + }); +}); + +QUnit.test('CodeView', function (assert) { + var done = assert.async(); + assert.expect(8); + + return weTestUtils.createWysiwyg({ + debug: false, + wysiwygOptions: { + codeview: true, + }, + }).then(function (wysiwyg) { + wysiwyg.setValue('<p>dom to edit <img src="/web_editor/static/src/img/transparent.png"></p>'); + + var $buttonCodeView = wysiwyg.$('button:has(.note-icon-code)'); + + // hide popover in CodeView mode and no range error when edit the content + var $editable = wysiwyg.getEditable(); + $editable.find('img').mousedown().click(); + assert.strictEqual($('.note-popover.note-image-popover:visible').length, 1, "should display the image popover"); + $buttonCodeView.mousedown().click(); + assert.strictEqual($('textarea.note-codable:visible').length, 1, "should show the CodeView textarea"); + assert.strictEqual($editable.is(':visible'), false, "should hide the editable area"); + assert.strictEqual($('.note-popover:visible').length, 0, "should hide all popovers"); + assert.strictEqual($('.note-toolbar button:not(.disabled)').length, 1, "should disabled all buttons expect the codeview button"); + wysiwyg.$('textarea.note-codable').val('<p>dom to edit a <img src="/web_editor/static/src/img/transparent.png"></p>'); + $buttonCodeView.mousedown().click(); + assert.strictEqual($('textarea.note-codable:visible').length, 0, "should hide the CodeView textarea"); + assert.strictEqual($editable.is(':visible'), true, "should show the editable area"); + assert.strictEqual($editable[0].style.height, '', "should reset the height (not auto or sizing)"); + // end + + wysiwyg.destroy(); + done(); + }); +}); + +}); + +var defMediaDialogInit; +var defMediaDialogSave; +var defCropDialogInit; +var defCropDialogSave; +var defAltDialogInit; +var defAltDialogSave; +var imgWidth = 10; +var imgHeight = 10; + +QUnit.module('Media', { + beforeEach: function () { + testUtils.mock.patch(MediaDialog, { + init: function () { + this._super.apply(this, arguments); + defMediaDialogInit = $.Deferred(); + defMediaDialogSave = $.Deferred(); + this.opened(defMediaDialogInit.resolve.bind(defMediaDialogInit)); + }, + save: function () { + $.when(this._super.apply(this, arguments)).then(function () { + var def = defMediaDialogSave; + defMediaDialogInit = null; + defMediaDialogSave = null; + def.resolve(); + }); + }, + }); + testUtils.mock.patch(CropDialog, { + init: function () { + $(arguments[2]).attr('src', $(arguments[2]).data('src')); + this._super.apply(this, arguments); + var self = this; + defCropDialogInit = $.Deferred(); + defCropDialogSave = $.Deferred(); + this.opened(function () { + var cropper = self.$cropperImage.data('cropper'); + cropper.clone(); + $.extend(cropper.image, { + naturalWidth: imgWidth, + naturalHeight: imgHeight, + aspectRatio: imgWidth / imgHeight, + }); + cropper.loaded = true; + cropper.build(); + cropper.render(); + defCropDialogInit.resolve(); + }); + }, + save: function () { + $.when(this._super.apply(this, arguments)).then(function () { + var def = defCropDialogSave; + defCropDialogInit = null; + defCropDialogSave = null; + def.resolve(); + }); + }, + }); + testUtils.mock.patch(AltDialog, { + init: function () { + this._super.apply(this, arguments); + defAltDialogInit = $.Deferred(); + defAltDialogSave = $.Deferred(); + this.opened(function () { + defAltDialogInit.resolve(); + }); + }, + save: function () { + $.when(this._super.apply(this, arguments)).then(function () { + var def = defAltDialogSave; + defAltDialogInit = null; + defAltDialogSave = null; + def.resolve(); + }); + }, + }); + $('body').on('submit.WysiwygTests', function (ev) { + ev.preventDefault(); + var $from = $(ev.target); + var iframe = $from.find('iframe[name="' + $from.attr('target') + '"]')[0]; + if (iframe) { + iframe.contentWindow.attachments = [{ + id: 1, + public: true, + name: 'image', + datas_fname: 'image.png', + mimetype: 'image/png', + checksum: false, + url: '/web_editor/static/src/img/transparent.png', + type: 'url', + res_id: 0, + res_model: false, + access_token: false + }]; + $(iframe).trigger('load'); + } + }); + + this.data = { + debug: false, + wysiwygOptions: { + + }, + mockRPC: function (route, args) { + if (args.model === 'ir.attachment' || !args.length) { + if (!args.length && route.indexOf('data:image/png;base64') === 0 || + args.method === "search_read" && + args.kwargs.domain[7][2].join(',') === "image/gif,image/jpe,image/jpeg,image/jpg,image/gif,image/png") { + return $.when(this.data.records); + } + } + if (route.indexOf('youtube') !== -1) { + return $.when(); + } + return this._super(route, args); + }, + }; + }, + afterEach: function () { + testUtils.mock.unpatch(MediaDialog); + testUtils.mock.unpatch(CropDialog); + testUtils.mock.unpatch(AltDialog); + $('body').off('submit.WysiwygTests'); + }, +}, function () { + + +var _clickMedia = function (wysiwyg, assert, callbackInit, test) { + wysiwyg.$('.note-insert .fa-file-image-o').mousedown().click(); + + defMediaDialogSave.then(function () { + if (test.check) { + test.check(); + } + if (test.content) { + assert.deepEqual(wysiwyg.getValue(), test.content, testName); + } + if (test.start) { + var range = weTestUtils.select(test.start, test.end, $editable); + assert.deepEqual(Wysiwyg.getRange($editable[0]), range, testName + carretTestSuffix); + } + }); + return defMediaDialogInit.then(function () { + callbackInit(); + var def = $.Deferred(); + setTimeout(function () { + def.resolve(); + }, 0); + return def; + }); +}; +var _uploadAndInsertImg = function (url) { + $('.modal-dialog input[name="url"]:first').val(url).trigger('input'); + $('.modal-dialog .o_upload_media_url_button:first').mousedown().click(); +}; +var _insertVideo = function (wysiwyg, assert, url, checkOptions) { + $('.modal-dialog .nav-link:contains("Video")').mousedown().click(); + $('.modal-dialog #o_video_text').val(url).trigger('change'); + if (checkOptions) { + assert.strictEqual($('.modal-dialog .o_yt_option').parent().css('display'), 'block', testName + ' (options)'); + } + $('.modal-dialog .modal-footer .btn.btn-primary').mousedown().click(); +}; +var _insertPictogram = function (className) { + $('.modal-dialog .nav-link:contains("Pictogram")').mousedown().click(); + $('.modal-dialog .font-icons-icons .font-icons-icon.fa.' + className).mousedown().click(); + $('.modal-dialog .modal-footer .btn.btn-primary').mousedown().click(); +}; +var _valueToRatio = function (value) { + return value < 0 ? 1 / (1 - value) : 1 + value; +}; + + +QUnit.test('Image', function (assert) { + var done = assert.async(); + assert.expect(13); + + return weTestUtils.createWysiwyg(this.data).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var mediaTests = [{ + name: "Click ADD AN IMAGE URL in empty p: p -> img in p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<p>\u200B<img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png">\u200B</p>', + check: function () { + assert.strictEqual($('.note-image-popover').css('display'), 'block', testName + ' (popover)'); + }, + }, + }, + { + name: "add an image in a table", + async: true, + content: '<section><div class="container"><div class="row"><div class="col-lg-6">' + + '<table class="table table-bordered">' + + ' <tbody>' + + ' <tr>' + + ' <td>' + + ' aaa' + + ' </td>' + + ' <td>' + + ' bbb' + + ' </td>' + + ' <td>' + + ' ccc' + + ' </td>' + + ' </tr>' + + ' </tbody>' + + '</table>' + + '</div></div></div></section>', + start: "td:eq(1):contents()[0]->18", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<section><div class="container"><div class="row"><div class="col-lg-6">' + + '<table class="table table-bordered">' + + ' <tbody>' + + ' <tr>' + + ' <td>' + + ' aaa' + + ' </td>' + + ' <td>' + + ' bb<img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png">b' + + ' </td>' + + ' <td>' + + ' ccc' + + ' </td>' + + ' </tr>' + + ' </tbody>' + + '</table>' + + '</div></div></div></section>', + check: function () { + assert.strictEqual($('.note-image-popover').css('display'), 'block', testName + ' (popover)'); + }, + }, + }, + { + name: "add an image in an empty table", + async: true, + content: '<section><div class="container"><div class="row"><div class="col-lg-6">' + + '<table class="table table-bordered">' + + ' <tbody>' + + ' <tr>' + + ' <td>' + + ' <br>' + + ' </td>' + + ' <td>' + + ' <br>' + + ' </td>' + + ' <td>' + + ' <br>' + + ' </td>' + + ' </tr>' + + ' </tbody>' + + '</table>' + + '</div></div></div></section>', + start: "br:eq(1)->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<section><div class="container"><div class="row"><div class="col-lg-6">' + + '<table class="table table-bordered">' + + ' <tbody>' + + ' <tr>' + + ' <td>' + + ' <br>' + + ' </td>' + + ' <td>' + + ' <img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png"><br>' + + ' </td>' + + ' <td>' + + ' <br>' + + ' </td>' + + ' </tr>' + + ' </tbody>' + + '</table>' + + '</div></div></div></section>', + check: function () { + assert.strictEqual($('.note-image-popover').css('display'), 'block', testName + ' (popover)'); + }, + }, + }, + /* IMAGE POPOVER */ + { + name: "Click PADDING XL in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<p>\u200B<img class="img-fluid o_we_custom_image padding-xl" data-src="/web_editor/static/src/img/transparent.png">\u200B</p>', + check: function () { + $('.note-image-popover .note-padding .dropdown-toggle').mousedown().click(); + $('.note-image-popover .note-padding .dropdown-item:contains("Xl")').mousedown().click(); + }, + }, + }, + { + name: "Click IMAGE SIZE 25% in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<p>\u200B<img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png" style="width: 25%;">\u200B</p>', + check: function () { + $('.note-image-popover .note-imagesize .note-btn:contains(25%)').mousedown().click(); + }, + }, + }, + { + name: "Click FLOAT RIGHT in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<p>\u200B<img class="img-fluid o_we_custom_image pull-right" data-src="/web_editor/static/src/img/transparent.png">\u200B</p>', + check: function () { + $('.note-image-popover .note-float .note-icon-align-right').mousedown().click(); + }, + }, + }, + { + name: "Click FLOAT CENTER then FLOAT LEFT in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<p>\u200B<img class="img-fluid o_we_custom_image pull-left" data-src="/web_editor/static/src/img/transparent.png">\u200B</p>', + check: function () { + $('.note-image-popover .note-float .note-icon-align-center').mousedown().click(); + $('.note-image-popover .note-float .note-icon-align-left').mousedown().click(); + }, + }, + }, + { + name: "Click SHAPE ROUNDED in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<p>\u200B<img class="img-fluid o_we_custom_image rounded" data-src="/web_editor/static/src/img/transparent.png">\u200B</p>', + check: function () { + $('.note-image-popover .note-imageShape .note-btn:has(.fa-square)').mousedown().click(); + }, + }, + }, + // Remove picture + { + name: "Click REMOVE in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + content: '<p><br></p>', + check: function () { + $('.note-image-popover .note-btn .note-icon-trash').mousedown().click(); + }, + }, + }, + // Describe picture + { + name: "Click DESCRIPTION in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + check: function () { + $('.note-image-popover .note-btn:contains("Description")').mousedown().click(); + defAltDialogSave.then(function () { + assert.deepEqual(wysiwyg.getValue(), + '<p>\u200B<img class="img-fluid o_we_custom_image" data-src="/web_editor/static/src/img/transparent.png" alt="Description" title="Title">\u200B</p>', + testName); + }); + defAltDialogInit.then(function () { + $('.modal-dialog input#alt').val('Description'); + $('.modal-dialog input#title').val('Title'); + $('.modal-dialog .modal-footer .btn.btn-primary:contains("Save")').mousedown().click(); + }); + }, + }, + }, + ]; + + var def = $.when(); + _.each(mediaTests, function (test) { + def = def.then(function () { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + return _clickMedia(wysiwyg, assert, test.do, test.test); + }); + }); + def.then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +QUnit.test('Image crop', function (assert) { + var done = assert.async(); + assert.expect(5); + + return weTestUtils.createWysiwyg(this.data).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var mediaTests = [{ + name: "Click CROP 16:9 + ZOOM IN in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + check: function () { + var zoomRatio; + $('.note-image-popover .note-btn:has(.fa-crop)').mousedown().click(); + defCropDialogSave.then(function () { + var $img = $(wysiwyg.getValue()).find('img.o_cropped_img_to_save'); + assert.strictEqual($img.data('aspect-ratio'), '16/9', testName + " (aspect-ratio)"); + assert.strictEqual($img.data('width'), imgWidth / zoomRatio, testName + " (zoom)"); + }); + defCropDialogInit.then(function () { + $('.o_crop_image_dialog .o_crop_options .btn:contains("16:9")').mousedown().click(); + var $zoomBtn = $('.o_crop_image_dialog .o_crop_options .btn:has(.fa-search-plus)'); + zoomRatio = _valueToRatio(Number($zoomBtn.data('value'))); + $zoomBtn.mousedown().click(); + $('.modal-dialog .modal-footer .btn.btn-primary:contains("Save")').mousedown().click(); + }); + }, + }, + }, + { + name: "Click CROP ROTATE LEFT + FLIP HORIZONTAL in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + check: function () { + $('.note-image-popover .note-btn:has(.fa-crop)').mousedown().click(); + defCropDialogSave.then(function () { + var $img = $(wysiwyg.getValue()).find('img.o_cropped_img_to_save'); + assert.strictEqual($img.data('rotate'), -45, testName + " (rotate)"); + assert.strictEqual($img.data('scale-x'), -1, testName + " (flip)"); + }); + defCropDialogInit.then(function () { + $('.o_crop_image_dialog .o_crop_options .btn:contains("16:9")').mousedown().click(); + $('.o_crop_image_dialog .o_crop_options .btn:has(.fa-rotate-left)').mousedown().click(); + $('.o_crop_image_dialog .o_crop_options .btn:has(.fa-arrows-h)').mousedown().click(); + $('.modal-dialog .modal-footer .btn.btn-primary:contains("Save")').mousedown().click(); + }); + }, + }, + }, + { + name: "Click CROP FREE in popover after adding image in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _uploadAndInsertImg('https://www.odoo.com/logo.png'); + }, + test: { + check: function () { + var cropFactor = 10; + $('.note-image-popover .note-btn:has(.fa-crop)').mousedown().click(); + defCropDialogSave.then(function () { + var $img = $(wysiwyg.getValue()).find('img.o_cropped_img_to_save'); + assert.strictEqual(Math.round($img.data('width')), Math.round(imgWidth - (imgWidth / cropFactor)), testName + " (rotate)"); + }); + defCropDialogInit.then(function () { + var $cropperPoints = $('.modal-dialog .cropper-crop-box .cropper-point'); + var $pointW = $cropperPoints.filter('.point-w'); + var pos1 = $pointW.offset(); + var cropperWidth = $cropperPoints.filter('.point-e').offset().left - pos1.left; + $pointW.trigger($.Event("pointerdown", { + pageX: pos1.left, + pageY: pos1.top, + })); + $pointW.trigger($.Event("pointermove", { + pageX: pos1.left + (cropperWidth / cropFactor), + pageY: pos1.top, + })); + $pointW.trigger('pointerup'); + $('.modal-dialog .modal-footer .btn.btn-primary:contains("Save")').mousedown().click(); + }); + }, + }, + }, + ]; + + var def = $.when(); + _.each(mediaTests, function (test) { + def = def.then(function () { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + return _clickMedia(wysiwyg, assert, test.do, test.test); + }); + }); + def.then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +QUnit.test('Pictogram (fontawesome)', function (assert) { + var done = assert.async(); + assert.expect(8); + + return weTestUtils.createWysiwyg(this.data).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var mediaTests = [{ + name: "Add PICTOGRAM in empty p: p -> span.fa in p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + content: '<p>\u200B<span class="fa fa-glass"></span>\u200B</p>', + check: function () { + assert.strictEqual($('.note-icon-popover').css('display'), 'block', testName + ' (popover)'); + }, + }, + }, + // Remove font + { + name: "Click REMOVE in popover after adding font in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + content: '<p><br></p>', + check: function () { + $('.note-image-popover .note-btn .note-icon-trash').mousedown().click(); + }, + }, + }, + // Icon size + { + name: "Click ICON SIZE then 5X in popover after adding pictogram in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + content: '<p>\u200B<span class="fa fa-glass fa-5x"></span>\u200B</p>', + check: function () { + $('.note-icon-popover .note-faSize .dropdown-toggle').mousedown().click(); + $('.note-icon-popover .note-faSize .dropdown-item:contains("5x")').mousedown().click(); + assert.ok($('.note-icon-popover .note-faSize .dropdown-item:contains("5x")').hasClass('active'), testName + ' (popover)'); + }, + }, + }, + // Spin + { + name: "Click SPIN in popover after adding pictogram in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + content: '<p>\u200B<span class="fa fa-glass fa-spin"></span>\u200B</p>', + check: function () { + $('.note-icon-popover .note-faSpin .note-btn').mousedown().click(); + }, + }, + }, + // Replace pictogram + { + name: "Add PICTOGRAM in empty p then replace it", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + check: function () { + _clickMedia(wysiwyg, assert, function () { + _insertPictogram('fa-heart'); + }, { + content: '<p>\u200B<span class="fa fa-heart"></span>\u200B</p>', + }); + }, + }, + }, + { + name: "Replace PICTOGRAM (i.fa => span.fa)", + async: true, + content: '<div class="row">\n' + + ' <div class="col-lg-12">\n' + + ' <i class="fa fa-heart rounded-circle"></i>\n' + + ' <div>Other content</div>\n' + + ' </div>\n' + + '</div>', + start: "i->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + content: '<div class="row">\n' + + ' <div class="col-lg-12">\n' + + ' <span class="rounded-circle fa fa-glass"></span>\n' + + ' <div>Other content</div>\n' + + ' </div>\n' + + '</div>', + }, + }, + ]; + + var def = $.when(); + _.each(mediaTests, function (test) { + def = def.then(function () { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + $(range.sc).mousedown().click(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + return _clickMedia(wysiwyg, assert, test.do, test.test); + }); + }); + def.then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + +QUnit.test('Video', function (assert) { + var done = assert.async(); + assert.expect(10); + + return weTestUtils.createWysiwyg(this.data).then(function (wysiwyg) { + var $editable = wysiwyg.$('.note-editable'); + + var mediaTests = [{ + name: "Add VIDEO (youtube) in empty p: p -> div.media_iframe_video after p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertVideo(wysiwyg, assert, 'https://www.youtube.com/watch?v=xxxxxxxxxxx', true); + }, + test: { + content: '<p><br></p><div class="media_iframe_video" data-oe-expression="about:blank"><div class="css_editable_mode_display"> </div><div class="media_iframe_video_size"> </div><iframe src="about:blank" frameborder="0"></iframe></div><p><br></p>', + check: function () { + assert.strictEqual($('.note-video-popover').css('display'), 'block', testName + ' (popover)'); + }, + }, + }, + { + name: "Add VIDEO (youtube) in p in breakable in unbreakable in breakable: p -> div.media_iframe_video after p", + async: true, + content: '<breakable><unbreakable><breakable><p>tata yoyo</p></breakable></unbreakable></breakable>', + start: "p:contents()[0]->4", + do: function () { + _insertVideo(wysiwyg, assert, 'https://www.youtube.com/watch?v=xxxxxxxxxxx'); + }, + test: { + content: '<breakable><unbreakable><breakable><p>tata</p></breakable>' + + '<div class="media_iframe_video" data-oe-expression="about:blank"><div class="css_editable_mode_display"> </div><div class="media_iframe_video_size"> </div><iframe src="about:blank" frameborder="0"></iframe></div>' + + '<breakable><p> yoyo</p></breakable></unbreakable></breakable>', + check: function () { + assert.strictEqual($('.note-video-popover').css('display'), 'block', testName + ' (popover)'); + }, + }, + }, + // Remove video + { + name: "Click REMOVE in popover after adding video in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertVideo(wysiwyg, assert, 'https://www.youtube.com/watch?v=xxxxxxxxxxx'); + }, + test: { + content: '<p><br></p>', + check: function () { + $('.note-image-popover .note-btn .note-icon-trash').mousedown().click(); + }, + }, + }, + /* VIDEO POPOVER */ + // Multiple clicks + { + name: "Click FLOAT CENTER then FLOAT LEFT in popover after adding youtube video in empty p", + async: true, + content: '<p><br></p>', + start: "p->0", + do: function () { + _insertVideo(wysiwyg, assert, 'https://www.youtube.com/watch?v=xxxxxxxxxxx'); + }, + test: { + content: '<p><br></p><div class="media_iframe_video pull-left" data-oe-expression="about:blank"><div class="css_editable_mode_display"> </div><div class="media_iframe_video_size"> </div><iframe src="about:blank" frameborder="0"></iframe></div><p><br></p>', + check: function () { + $('.note-image-popover .note-float .note-icon-align-center').mousedown().click(); + $('.note-image-popover .note-float .note-icon-align-left').mousedown().click(); + }, + }, + }, + // Replace picture + { + name: "replace picture with video", + async: true, + content: '<p><img src="https://www.odoo.com/logo.png"></p>', + start: "img->0", + do: function () { + _insertVideo(wysiwyg, assert, 'https://www.youtube.com/watch?v=xxxxxxxxxxx'); + }, + test: { + check: function () { + assert.deepEqual(wysiwyg.getValue(), + '<p><br></p><div class="media_iframe_video" data-oe-expression="about:blank"><div class="css_editable_mode_display"> </div><div class="media_iframe_video_size"> </div><iframe src="about:blank" frameborder="0"></iframe></div><p><br></p>', + testName); + }, + }, + }, + // Replace video + { + name: "replace video by pictogram", + async: true, + content: '<p><br></p><div class="media_iframe_video" data-oe-expression="about:blank"><div class="css_editable_mode_display"> </div><div class="media_iframe_video_size"> </div><iframe src="about:blank" frameborder="0"></iframe></div><p><br></p>', + start: "div->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + check: function () { + assert.deepEqual(wysiwyg.getValue(), + '<p>\u200B<span class="fa fa-glass"></span>\u200B</p>', + testName); + }, + }, + }, + { + name: "replace video by pictogram (2)", + async: true, + content: '<p>aaa</p><div class="media_iframe_video" data-oe-expression="about:blank"><div class="css_editable_mode_display"> </div><div class="media_iframe_video_size"> </div><iframe src="about:blank" frameborder="0"></iframe></div><p>bbb</p>', + start: "div->0", + do: function () { + _insertPictogram('fa-glass'); + }, + test: { + check: function () { + assert.deepEqual(wysiwyg.getValue(), + '<p>aaa<span class="fa fa-glass"></span>bbb</p>', + testName); + }, + }, + }, + ]; + + var def = $.when(); + _.each(mediaTests, function (test) { + def = def.then(function () { + testName = test.name; + wysiwyg.setValue(test.content); + var range = weTestUtils.select(test.start, test.end, $editable); + $(range.sc).mousedown().click(); + Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo); + return _clickMedia(wysiwyg, assert, test.do, test.test); + }); + }); + def.then(function () { + wysiwyg.destroy(); + done(); + }); + }); +}); + + +}); +}); +}); +}); diff --git a/addons/web_editor/tests/__init__.py b/addons/web_editor/tests/__init__.py index b531e18c918..8b77588a13c 100644 --- a/addons/web_editor/tests/__init__.py +++ b/addons/web_editor/tests/__init__.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_ui from . import test_converter diff --git a/addons/web_editor/tests/test_ui.py b/addons/web_editor/tests/test_ui.py deleted file mode 100644 index 9e9527e2d14..00000000000 --- a/addons/web_editor/tests/test_ui.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -import odoo.tests - -@odoo.tests.tagged('post_install', '-at_install') -class TestUi(odoo.tests.HttpCase): - - def test_01_admin_rte(self): - self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('rte')", "odoo.__DEBUG__.services['web_tour.tour'].tours.rte.ready", login='admin') - - def test_02_admin_rte_inline(self): - self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('rte_inline')", "odoo.__DEBUG__.services['web_tour.tour'].tours.rte_inline.ready", login='admin') diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index b67d8ee7771..dae6e7c91ad 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -1,55 +1,80 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> -<template id="summernote" name="Summernote"> - <script type="text/javascript"> - (function () { - "use strict"; - odoo.define('jquery', function () {return $;}); - - var uniqId = 0; - odoo.__define__ = window.define; - window.define = function (id) { - var args = Array.prototype.slice.call(arguments); - var factorie = args.pop(); - var id = args[0]; - - if (id instanceof Array) { - var name = "summernote_factorie_" + (++uniqId); - odoo[name] = factorie; - - var head = ''; - var fn = 'var fn = odoo.'+name+';\ndelete odoo.'+name+';\n'; - fn += 'return fn('; - for (var k=0; k<id.length; k++) { - head += 'var a'+(++uniqId)+' = require("'+id[k]+'");\n'; - fn += 'a'+uniqId+', '; - } - fn += 'null);'; - - odoo.define(odoo.website_next_define, new Function('require', head + fn)); - } else { - odoo.define(id, factorie); - } - }; - })(); - </script> - <t t-set="summer_js_files" t-value="['core/async', 'core/func', 'core/agent', 'core/list', - 'core/dom', 'core/key', 'core/range', 'editing/Bullet', 'editing/History', 'editing/Style', - 'editing/Table', 'editing/Typing', 'module/Editor', 'module/Button', 'module/Clipboard', - 'module/Codeview', 'module/DragAndDrop', 'module/Fullscreen', 'module/Handle', - 'module/HelpDialog', 'module/ImageDialog', 'module/LinkDialog', 'module/Popover', - 'module/Statusbar', 'module/Toolbar', 'Renderer', 'EventHandler', 'defaults', 'summernote']" /> - <t t-foreach="summer_js_files" t-as="file"> - <script>odoo.website_next_define = 'summernote/<t t-esc="file" />';</script> - <script type="text/javascript" t-attf-src="/web_editor/static/lib/summernote/src/js/{{ file }}.js"></script> - </t> - <script type="text/javascript" src="/web_editor/static/src/js/editor/summernote.js" /> - <script type="text/javascript"> - window.define = odoo.__define__; - delete odoo.__define__; - delete odoo.website_next_define; - </script> - <link rel="stylesheet" type="text/css" href="/web_editor/static/lib/summernote/src/css/summernote.css"/> + +<template id="wysiwyg_iframe_css_assets" name="CSS assets for wysiwyg iframe content"> + <t t-call-assets="web.assets_common" t-js="false"/> + <t t-call-assets="web.assets_backend" t-js="false"/> +</template> + +<template id="wysiwyg" name="Wysiwyg Editor"> + <!-- lib --> + + <link rel="stylesheet" type="text/css" href="/web_editor/static/lib/summernote/summernote.css"/> + <link rel="stylesheet" type="text/css" href="/web_editor/static/lib/cropper/css/cropper.css"/> + + <script type="text/javascript" src="/web_editor/static/lib/summernote/summernote.js"/> + <script type="text/javascript" src="/web_editor/static/lib/cropper/js/cropper.js"/> + <script type="text/javascript" src="/web_editor/static/lib/jQuery.transfo.js"/> + + <!-- integration --> + + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/wysiwyg_variables.scss"/> + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/wysiwyg.scss"/> + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/wysiwyg_iframe.scss"/> + + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/wysiwyg.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin_registry.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/options.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/translation.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/fonts.js"/> + + <!-- widgets & plugins --> + + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/media.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/dialog.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/widgets.js"/> + + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/abstract.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/plugins.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/helper.js"/> + + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/toolbar.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/dropzone.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/history.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/table.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/text.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/editor.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/media.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/buttons.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/link.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/font.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/keyboard.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/transform.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/hint.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/bullet.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/codeview.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js"/> +</template> + +<template id="wysiwyg_snippets" inherit_id="web_editor.wysiwyg" name="Wysiwyg Editor additional snippets"> + <xpath expr="." position="inside"> + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/wysiwyg_variables.scss"/> + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/wysiwyg_snippets.scss"/> + + <script type="text/javascript" src="/web_editor/static/lib/nearest/jquery.nearest.js"/> + + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js"/> + </xpath> </template> <template id="_assets_primary_variables" inherit_id="web._assets_primary_variables"> @@ -66,9 +91,9 @@ <template id="assets_common" inherit_id="web.assets_common" name="Web Editor Common Assets (used in backend interface and website)"> <xpath expr="script[last()]" position="after"> - <script type="text/javascript" src="/web_editor/static/lib/vkbeautify/vkbeautify.0.99.00.beta.js"></script> - <script type="text/javascript" src="/web_editor/static/src/js/base.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/widgets/ace.js"/> + <script type="text/javascript" src="/web_editor/static/lib/vkbeautify/vkbeautify.0.99.00.beta.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/common/ace.js"/> + <t t-call="web_editor.wysiwyg"/> </xpath> </template> @@ -85,7 +110,8 @@ <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/web_editor.backend.scss"/> </xpath> <xpath expr="script[last()]" position="after"> - <script type="text/javascript" src="/web_editor/static/src/js/backend/fields.js" /> + <script type="text/javascript" src="/web_editor/static/src/js/backend/field_html.js" /> + <script type="text/javascript" src="/web_editor/static/src/js/backend/convert_inline.js"/> </xpath> </template> @@ -100,47 +126,17 @@ <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/web_editor.common.scss"/> <!-- not in common because compiled differently with context --> </xpath> <xpath expr="//script[last()]" position="after"> - <script type="text/javascript" src="/web_editor/static/src/js/content/body_manager.js"/> <script type="text/javascript" src="/web_editor/static/src/js/root_widget.js"/> </xpath> </template> -<template id="assets_editor" name="Web Editor Assets (used in private and public editor)"> - <t t-call="web._assets_helpers"/> - - <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/web_editor.ui.scss"/> - - <script type="text/javascript" src="/web_editor/static/lib/nearest/jquery.nearest.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/editor/editor.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/editor/rte.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/editor/rte.summernote.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/editor/snippets.editor.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/editor/snippets.options.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/editor/transcoder.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/editor/translator.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/tours/rte.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/widgets/widgets.js"/> -</template> - -<template id="webclient_bootstrap" inherit_id="web.webclient_bootstrap"> - <xpath expr="//t[@t-call-assets='web.assets_backend'][@t-js='false']" position="after"> - <t t-call-assets="web_editor.summernote" t-js="false"/> - <t t-call-assets="web_editor.assets_editor" t-js="false"/> - </xpath> - <xpath expr="//t[@t-call-assets='web.assets_backend'][@t-css='false']" position="after"> - <t t-call-assets="web_editor.summernote" t-css="false"/> - <t t-call-assets="web_editor.assets_editor" t-css="false"/> - </xpath> -</template> -<template id="js_tests_assets" inherit_id="web.js_tests_assets"> - <xpath expr="." position="inside"> - <t t-call-assets="web_editor.summernote" t-css="false"/> - <t t-call-assets="web_editor.assets_editor" t-css="false"/> - </xpath> -</template> <template id="qunit_suite" inherit_id="web.qunit_suite"> <xpath expr="." position="inside"> - <script type="text/javascript" src="/web_editor/static/tests/web_editor_tests.js"></script> + <script type="text/javascript" src="/web_editor/static/tests/test_utils.js"/> + <script type="text/javascript" src="/web_editor/static/tests/field_html_tests.js"/> + <script type="text/javascript" src="/web_editor/static/tests/wysiwyg_tests.js"/> + <script type="text/javascript" src="/web_editor/static/tests/wysiwyg_keyboard_tests.js"/> + <script type="text/javascript" src="/web_editor/static/tests/wysiwyg_snippets_tests.js"/> </xpath> </template> diff --git a/addons/web_editor/views/iframe.xml b/addons/web_editor/views/iframe.xml deleted file mode 100644 index dec49685b36..00000000000 --- a/addons/web_editor/views/iframe.xml +++ /dev/null @@ -1,80 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - -<template id="layout" name="Editor layout"><!DOCTYPE html> - <html t-att-lang="lang and lang.replace('_', '-')" - t-att-data-editable="'1' if editable else None" - t-att-data-translatable="'1' if translatable else None" - t-att-data-edit_translations="'1' if edit_translations else None" - t-att-data-main-object="repr(main_object) if editable else None"> - <head> - <meta charset="utf-8" /> - <title>Odoo Editor layout</title> - <meta name="viewport" content="initial-scale=1"/> - <script type="text/javascript"> - var callback = window.location.href.match(/callback=([^&=]+)/)[1]; - - var odoo = { - session_info: { - is_admin: <t t-esc="json.dumps(request.env.user._is_admin())"/>, - is_frontend: true, // this is not really the frontend here but this key is used to detect if xml has to be manually loaded - }, - _modules: ['web_editor'], - snippetsURL: '<t t-esc="snippets or ''"/>', - }; - </script> - <t t-if="not dont_load_assets"> - <t t-call-assets="web.assets_common" t-js="false"/> - <t t-call-assets="web.assets_frontend" t-js="false"/> - <t t-call-assets="web_editor.summernote" t-js="false"/> - <t t-call-assets="web_editor.assets_editor" t-js="false"/> - - <t t-call-assets="web.assets_common" t-css="false"/> - <t t-call-assets="web_editor.summernote" t-css="false"/> - <t t-call-assets="web_editor.assets_editor" t-css="false"/> - - <script type="text/javascript" src="/web/static/src/js/services/session.js"></script> - <script type="text/javascript" src="/web_editor/static/src/js/content/body_manager.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/root_widget.js"/> - <script type="text/javascript" src="/web_editor/static/src/js/iframe.js"></script> - <script t-if="enable_editor and inline_mode" type="text/javascript" src="/web_editor/static/src/js/inline.js"></script> - </t> - <script> - if (window.top.odoo && !window.top.odoo[callback+"_updown"]) { - window.top.odoo[callback+"_updown"] = function (value, fields_values) { - var editable = document.getElementsByClassName("o_editable")[0]; - if (editable && value !== editable.innerHTML) { - editable.innerHTML = value; - } - }; - } - if(window.odoo.define) { - odoo.define('web.csrf', function (require) { - var token = "<t t-esc="request.csrf_token(None)"/>"; - var core = require('web.core'); - core.csrf_token = token; - core.qweb.default_dict.csrf_token = token; - }); - } - </script> - <t t-raw="head or ''"/> - </head> - <t t-set="direction" t-value="request.env['res.lang'].search([('code', '=', request.env.lang)]).direction"/> - <body id="web_editor_inside_iframe" onload="var fn=window.top.odoo && window.top.odoo[callback+'_content']; if(fn) {fn();} else {console.warn('Please don\'t open iframe without the odoo backend');}" t-attf-style="direction: #{direction};"> - <div id="wrapwrap"> - <main> - <t t-raw="0"/> - </main> - </div> - <t t-raw="add_html or ''"/> - </body> - </html> -</template> - -<template id="FieldTextHtml" name="Editor HTML"> - <t t-call="web_editor.layout"> - <div id="editable_area" t-att-class="'' if edit_translations else 'o_editable'"></div> - </t> -</template> - -</odoo> diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml index 3d5cb0165ba..3a1c6614609 100644 --- a/addons/web_editor/views/snippets.xml +++ b/addons/web_editor/views/snippets.xml @@ -8,7 +8,9 @@ <div class="o_panel_header"> <i class="fa fa-th-large"/> First Panel </div> - <div class="o_panel_body"/> + <div class="o_panel_body"> + <t t-snippet="web_editor.s_hr" t-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png"/> + </div> </div> </div> @@ -23,5 +25,19 @@ <div data-js='many2one' data-selector="[data-oe-many2one-model]:not([data-oe-readonly])" data-no-check="true"/> + + <div data-js="content" + data-selector=".s_hr" + data-drop-near="p, h1, h2, h3, blockquote, .s_hr"> + </div> + </template> + +<!-- default block --> +<template id="s_hr" name="Separator"> + <div class="s_hr pt32 pb32"> + <hr class="s_hr_1px s_hr_solid w-100 mx-auto"/> + </div> +</template> + </odoo> diff --git a/addons/web_tour/static/src/js/running_tour_action_helper.js b/addons/web_tour/static/src/js/running_tour_action_helper.js index 1c4df6c27db..479f3a2ea78 100644 --- a/addons/web_tour/static/src/js/running_tour_action_helper.js +++ b/addons/web_tour/static/src/js/running_tour_action_helper.js @@ -92,7 +92,11 @@ var RunningTourActionHelper = core.Class.extend({ $selectedOption.prop("selected", true); this._click(values); } else { + values.$element.focusIn(); + values.$element.trigger($.Event( "keydown", {key: '_', keyCode: 95})); values.$element.text(text); + values.$element.focusInEnd(); + values.$element.trigger($.Event( "keyup", {key: '_', keyCode: 95})); } values.$element.trigger("change"); }, diff --git a/addons/web_unsplash/static/src/js/unsplash_image_widget.js b/addons/web_unsplash/static/src/js/unsplash_image_widget.js index 4338b7d6dd0..61a698ed190 100644 --- a/addons/web_unsplash/static/src/js/unsplash_image_widget.js +++ b/addons/web_unsplash/static/src/js/unsplash_image_widget.js @@ -3,15 +3,15 @@ odoo.define('web_unsplash.image_widgets', function (require) { var core = require('web.core'); var UnsplashAPI = require('unsplash.api'); -var weWidgets = require('web_editor.widget'); +var widgetsMedia = require('wysiwyg.widgets.media'); var unsplashAPI = null; -weWidgets.ImageWidget.include({ - xmlDependencies: weWidgets.ImageWidget.prototype.xmlDependencies.concat( +widgetsMedia.ImageWidget.include({ + xmlDependencies: widgetsMedia.ImageWidget.prototype.xmlDependencies.concat( ['/web_unsplash/static/src/xml/unsplash_image_widget.xml'] ), - events: _.extend({}, weWidgets.ImageWidget.prototype.events, { + events: _.extend({}, widgetsMedia.ImageWidget.prototype.events, { 'dblclick .unsplash_img_container [data-imgid]': '_onUnsplashImgDblClick', 'click .unsplash_img_container [data-imgid]': '_onUnsplashImgClick', 'click button.save_unsplash': '_onSaveUnsplash', diff --git a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml index 9a474e6041a..dc680ef00c4 100644 --- a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml +++ b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <templates id="template" xml:space="preserve"> -<t t-extend="web_editor.dialog.image"> +<t t-extend="wysiwyg.widgets.image"> <t t-jquery=".existing-attachments" t-operation="after"> <div class="unsplash_img_container"></div> </t> diff --git a/addons/web_unsplash/views/web_unsplash_templates.xml b/addons/web_unsplash/views/web_unsplash_templates.xml index 71951874998..0d11afef632 100644 --- a/addons/web_unsplash/views/web_unsplash_templates.xml +++ b/addons/web_unsplash/views/web_unsplash_templates.xml @@ -1,13 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <template id="web_unsplash_assets" inherit_id="web_editor.assets_editor" name="Unsplash Images Editor Assets"> + <template id="web_unsplash_assets" inherit_id="web_editor.wysiwyg" name="Unsplash Images Editor Assets"> <xpath expr="." position="inside"> <link rel="stylesheet" type="text/scss" href="/web_unsplash/static/src/scss/unsplash.scss"/> <script type="text/javascript" src="/web_unsplash/static/src/js/unsplashapi.js"></script> <script type="text/javascript" src="/web_unsplash/static/src/js/unsplash_image_widget.js"></script> </xpath> </template> - <template id="assets_common" inherit_id="web_editor.assets_common" name="Unsplash Images Common Assets"> + <template id="assets_frontend" inherit_id="web.assets_frontend" name="Unsplash Images Assets"> <xpath expr="." position="inside"> <script type="text/javascript" src="/web_unsplash/static/src/js/unsplash_beacon.js"></script> </xpath> diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index de83a7cb8cf..3852fefe740 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -276,10 +276,6 @@ class Website(Home): return werkzeug.utils.redirect('/web#id=' + str(page.get('view_id')) + '&view_type=form&model=ir.ui.view') return werkzeug.utils.redirect(url + "?enable_editor=1") - @http.route(['/website/snippets'], type='json', auth="user", website=True) - def snippets(self): - return request.env['ir.ui.view'].render_template('website.snippets') - @http.route("/website/get_switchable_related_views", type="json", auth="user", website=True) def get_switchable_related_views(self, key): views = request.env["ir.ui.view"].get_related_views(key, bundles=False).filtered(lambda v: v.customize_show) diff --git a/addons/website/static/src/js/content/base.js b/addons/website/static/src/js/content/base.js new file mode 100644 index 00000000000..bc5aa9892d0 --- /dev/null +++ b/addons/website/static/src/js/content/base.js @@ -0,0 +1,28 @@ +odoo.define('web_editor.base', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var session = require('web.session'); + +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 in order to be loaded. This function returns a + * Deferred that 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's 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()); + }, +}; +}); diff --git a/addons/web_editor/static/src/js/content/body_manager.js b/addons/website/static/src/js/content/body_manager.js index 1045e94a65a..1045e94a65a 100644 --- a/addons/web_editor/static/src/js/content/body_manager.js +++ b/addons/website/static/src/js/content/body_manager.js diff --git a/addons/website/static/src/js/content/context.js b/addons/website/static/src/js/content/context.js new file mode 100644 index 00000000000..b76e7cb403d --- /dev/null +++ b/addons/website/static/src/js/content/context.js @@ -0,0 +1,24 @@ +odoo.define('web_editor.context', function (require) { +'use strict'; + +function getContext(context) { + var html = document.documentElement; + return _.extend({ + 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, +}; +}); diff --git a/addons/website/static/src/js/content/ready.js b/addons/website/static/src/js/content/ready.js new file mode 100644 index 00000000000..214e7c2c34f --- /dev/null +++ b/addons/website/static/src/js/content/ready.js @@ -0,0 +1,7 @@ +odoo.define('web_editor.ready', function (require) { +'use strict'; + +var base = require('web_editor.base'); + +return base.ready(); +}); diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index 587cbca61d4..65729146dfb 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -494,7 +494,9 @@ var Animation = Widget.extend({ /** * The registry object contains the list of available animations. */ -var registry = {}; +var Registry = require('web.Registry'); +var registryObject = new Registry(); +var registry = registryObject.map; registry.slider = Animation.extend({ selector: '.carousel', @@ -1108,5 +1110,6 @@ registry.anchorSlide = Animation.extend({ return { Class: Animation, registry: registry, + registryObject: registryObject, }; }); diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js index cf4812b28b9..b9f3bfe25e9 100644 --- a/addons/website/static/src/js/content/website_root.js +++ b/addons/website/static/src/js/content/website_root.js @@ -56,6 +56,7 @@ var WebsiteRoot = BodyManager.extend({ init: function () { this._super.apply(this, arguments); this.animations = []; + sAnimation.registryObject.onAdd(this._startMissingAnimations.bind(this)); }, /** * @override @@ -141,7 +142,7 @@ var WebsiteRoot = BodyManager.extend({ _startAnimations: function (editableMode, $from) { var self = this; - editableMode = editableMode || false; + this.startInEditableMode = editableMode || false; if ($from === undefined) { $from = this.$('#wrapwrap'); } @@ -153,7 +154,7 @@ var WebsiteRoot = BodyManager.extend({ var $target = $from.find(selector).addBack(selector); var defs = _.map($target, function (el) { - var animation = new Animation(self, editableMode); + var animation = new Animation(self, self.startInEditableMode); self.animations.push(animation); return animation.attachTo($(el)); }); @@ -161,6 +162,11 @@ var WebsiteRoot = BodyManager.extend({ }); return $.when.apply($, defs); }, + _startMissingAnimations: function () { + if (this.animations.length) { + this._startAnimations(this.startInEditableMode); + } + }, /** * Destroys all animation instances. Especially needed before saving while * in edition mode for example. diff --git a/addons/website/static/src/js/editor/editor_menu.js b/addons/website/static/src/js/editor/editor_menu.js new file mode 100644 index 00000000000..996b6b99e9c --- /dev/null +++ b/addons/website/static/src/js/editor/editor_menu.js @@ -0,0 +1,213 @@ +odoo.define('website.editor.menu', function (require) { +'use strict'; + +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var core = require('web.core'); +var weContext = require('web_editor.context'); +var WysiwygMultizone = require('web_editor.wysiwyg.multizone'); + +var _t = core._t; + +var EditorMenu = Widget.extend({ + template: 'website.editorbar', + xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + events: { + 'click button[data-action=save]': '_onSaveClick', + 'click button[data-action=cancel]': '_onCancelClick', + }, + custom_events: { + request_save: '_onSnippetRequestSave', + }, + + LOCATION_SEARCH: 'enable_editor', + + /** + * @override + */ + willStart: function () { + var self = this; + this.$el = null; // temporary null to avoid hidden error (@see start) + return this._super() + .then(function () { + var $wrapwrap = $('#wrapwrap'); + $wrapwrap.removeClass('o_editable'); // clean the dom before edition + self.editable($wrapwrap).addClass('o_editable'); + self.wysiwyg = self._wysiwygInstance(); + return self.wysiwyg.attachTo($wrapwrap); + }); + }, + /** + * @override + */ + start: function () { + var self = this; + this.$el.css({width: '100%'}); + return this._super().then(function () { + self.trigger_up('edit_mode'); + self.$el.css({width: ''}); + }); + }, + /** + * @override + */ + destroy: function () { + this.trigger_up('readonly_mode'); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Asks the user if they really wants to discard their changes (if any), + * then simply reloads the page if they want 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 (!this.wysiwyg.isDirty()) { + 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', def, def.reject); + } + return def.then(function () { + self.trigger_up('edition_will_stopped'); + var $wrapwrap = $('#wrapwrap'); + self.editable($wrapwrap).removeClass('o_editable'); + if (reload !== false) { + self.wysiwyg.destroy(); + return self._reload(); + } else { + self.wysiwyg.destroy(); + self.trigger_up('readonly_mode'); + self.trigger_up('edition_was_stopped'); + self.destroy(); + } + }); + }, + /** + * 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.trigger_up('edition_will_stopped'); + return this.wysiwyg.save().then(function (dirty) { + var $wrapwrap = $('#wrapwrap'); + self.editable($wrapwrap).removeClass('o_editable'); + if (dirty && reload !== false) { + // remove top padding because the connected bar is not visible + $('body').removeClass('o_connected_user'); + return self._reload(); + } else { + self.wysiwyg.destroy(); + self.trigger_up('edition_was_stopped'); + self.destroy(); + } + }); + }, + /** + * Returns the editable areas on the page. + * + * @param {DOM} $wrapwrap + * @returns {jQuery} + */ + editable: function ($wrapwrap) { + return $wrapwrap.find('[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'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _wysiwygInstance: function () { + return new WysiwygMultizone(this, { + snippets: 'website.snippets', + recordInfo: { + context: weContext.get(), + data_res_model: 'website', + data_res_id: weContext.get().website_id, + } + }); + }, + /** + * Reloads the page in non-editable mode, with the right scrolling. + * + * @private + * @returns {Deferred} (never resolved, the page is reloading anyway) + */ + _reload: function () { + $('body').addClass('o_wait_reload'); + this.wysiwyg.destroy(); + window.location.hash = 'scrollTop=' + window.document.body.scrollTop; + if (window.location.search.indexOf(this.LOCATION_SEARCH) >= 0) { + var regExp = new RegExp('[&?]' + this.LOCATION_SEARCH + '(=[^&]*)?', 'g'); + window.location.href = window.location.href.replace(regExp, '?'); + } else { + window.location.reload(true); + } + return $.Deferred(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "Discard" button is clicked -> discards the changes. + * + * @private + */ + _onCancelClick: function () { + this.cancel(false); + }, + /** + * Snippet (menu_data) can request to save the document to leave the page + * + * @private + * @param {OdooEvent} ev + * @param {object} ev.data + * @param {function} ev.data.onSuccess + * @param {function} ev.data.onFailure + */ + _onSnippetRequestSave: function (ev) { + this.save(false).then(ev.data.onSuccess, ev.data.onFailure); + }, + /** + * Called when the "Save" button is clicked -> saves the changes. + * + * @private + */ + _onSaveClick: function () { + this.save(); + }, +}); + +return EditorMenu; +}); diff --git a/addons/website/static/src/js/editor/editor_menu_translate.js b/addons/website/static/src/js/editor/editor_menu_translate.js new file mode 100644 index 00000000000..a99c0c1bc72 --- /dev/null +++ b/addons/website/static/src/js/editor/editor_menu_translate.js @@ -0,0 +1,105 @@ +odoo.define('website.editor.menu.translate', function (require) { +'use strict'; + +require('web_editor.ready'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var localStorage = require('web.local_storage'); +var weContext = require('web_editor.context'); +var WysiwygTranslate = require('web_editor.wysiwyg.multizone.translate'); +var EditorMenu = require('website.editor.menu'); + +var lang = $('html').attr('lang').replace('-', '_'); +if ($('.js_change_lang[data-lang="' + lang + '"]').data('default-lang')) { + return $.Deferred().reject("It's the default language"); +} + +var _t = core._t; + +var localStorageNoDialogKey = 'website_translator_nodialog'; + + +var TranslatorInfoDialog = Dialog.extend({ + template: 'website.TranslatorInfoDialog', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/website/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 TranslatorMenu = EditorMenu.extend({ + LOCATION_SEARCH: 'edit_translations', + + /** + * @override + */ + start: function () { + if (!localStorage.getItem(localStorageNoDialogKey)) { + new TranslatorInfoDialog(this).open(); + } + + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the editable areas on the page. + * + * @param {DOM} $wrapwrap + * @returns {jQuery} + */ + editable: function ($wrapwrap) { + var selector = '[data-oe-translation-id], '+ + '[data-oe-model][data-oe-id][data-oe-field], ' + + '[placeholder*="data-oe-translation-id="], ' + + '[title*="data-oe-translation-id="], ' + + '[alt*="data-oe-translation-id="]'; + var $edit = $wrapwrap.find(selector); + $edit.filter(':has(' + selector + ')').attr('data-oe-readonly', true); + return $edit.not('[data-oe-readonly]'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _wysiwygInstance: function () { + return new WysiwygTranslate(this, {lang: lang || weContext.get().lang}); + }, +}); + +return TranslatorMenu; + +}); diff --git a/addons/website/static/src/js/editor/rte.summernote.js b/addons/website/static/src/js/editor/rte.summernote.js deleted file mode 100644 index 36cadca7a8d..00000000000 --- a/addons/website/static/src/js/editor/rte.summernote.js +++ /dev/null @@ -1,59 +0,0 @@ -odoo.define('website.rte.summernote', function (require) { -'use strict'; - -var core = require('web.core'); -require('web_editor.rte.summernote'); - -var eventHandler = $.summernote.eventHandler; -var renderer = $.summernote.renderer; -var tplIconButton = renderer.getTemplate().iconButton; -var _t = core._t; - -var fn_tplPopovers = renderer.tplPopovers; -renderer.tplPopovers = function (lang, options) { - var $popover = $(fn_tplPopovers.call(this, lang, options)); - $popover.find('.note-image-popover .btn-group:has([data-value="img-thumbnail"])').append( - tplIconButton('fa fa-object-ungroup', { - title: _t('Transform the picture (click twice to reset transformation)'), - event: 'transform', - })); - return $popover; -}; - -$.summernote.pluginEvents.transform = function (event, editor, layoutInfo, sorted) { - var $selection = layoutInfo.handle().find('.note-control-selection'); - var $image = $($selection.data('target')); - - if ($image.data('transfo-destroy')) { - $image.removeData('transfo-destroy'); - return; - } - - $image.transfo(); - - var mouseup = function (event) { - $('.note-popover button[data-event="transform"]').toggleClass('active', $image.is('[style*="transform"]')); - }; - $(document).on('mouseup', mouseup); - - var mousedown = function (event) { - if (!$(event.target).closest('.transfo-container').length) { - $image.transfo('destroy'); - $(document).off('mousedown', mousedown).off('mouseup', mouseup); - } - if ($(event.target).closest('.note-popover').length) { - $image.data('transfo-destroy', true).attr('style', ($image.attr('style') || '').replace(/[^;]*transform[\w:]*;?/g, '')); - } - $image.trigger('content_changed'); - }; - $(document).on('mousedown', mousedown); -}; - -var fn_boutton_update = eventHandler.modules.popover.button.update; -eventHandler.modules.popover.button.update = function ($container, oStyle) { - fn_boutton_update.call(this, $container, oStyle); - $container.find('button[data-event="transform"]') - .toggleClass('active', $(oStyle.image).is('[style*="transform"]')) - .toggleClass('d-none', !$(oStyle.image).is('img')); -}; -}); diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 44ad7e92e11..b516de3c939 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -1,9 +1,9 @@ -odoo.define('website.snippets.options', function (require) { +odoo.define('website.editor.snippets.options', function (require) { 'use strict'; var core = require('web.core'); var Dialog = require('web.Dialog'); -var weWidgets = require('web_editor.widget'); +var weWidgets = require('wysiwyg.widgets'); var options = require('web_editor.snippets.options'); var _t = core._t; @@ -814,8 +814,8 @@ options.registry.ul = options.Class.extend({ this._super(); if (!this.$target.hasClass('o_ul_folded')) { this.$target.find('.o_close').removeClass('o_close'); + this.$target.find('li').css('list-style', ''); } - this.$target.find('li:not(:has(>ul))').css('list-style', ''); }, //-------------------------------------------------------------------------- @@ -844,13 +844,11 @@ options.registry.ul = options.Class.extend({ }) .prepend('<a href="#" class="o_ul_toggle_self fa" />'); var $li = this.$target.find('li:has(+li:not(>.o_ul_toggle_self)>ul, +li:not(>.o_ul_toggle_self)>ol)'); + $li.css('list-style', this.$target.hasClass('o_ul_folded') ? 'none' : ''); $li.map(function () { return $(this).children()[0] || this; }) .prepend('<a href="#" class="o_ul_toggle_next fa" />'); $li.removeClass('o_open').next().addClass('o_close'); - this.$target.find('li').removeClass('o_open').css('list-style', ''); - this.$target.find('li:has(.o_ul_toggle_self, .o_ul_toggle_next), li:has(>ul,>ol):not(:has(>li))').css('list-style', 'none'); - - this.$target.find('li:not(:has(>ul))').css('list-style', ''); + this.$target.find('li').removeClass('o_open'); this._refreshAnimations(); }, }); @@ -943,7 +941,7 @@ options.registry.gallery = options.Class.extend({ var self = this; // The snippet should not be editable - this.$target.attr('contentEditable', false); + this.$target.addClass('o_fake_not_editable').attr('contentEditable', false); // Make sure image previews are updated if images are changed this.$target.on('save', 'img', function (ev) { @@ -960,6 +958,21 @@ options.registry.gallery = options.Class.extend({ self.addImages(false); }); + this.$target.on('dropped', 'img', function (ev) { + self.mode(null, self.getMode()); + if (!ev.target.height) { + $(ev.target).one('load', function () { + setTimeout(function () { + self.trigger_up('cover_update'); + }); + }); + } + }); + + if (this.$('.container:first > *:not(div)').length) { + self.mode(null, self.getMode()); + } + return this._super.apply(this, arguments); }, /** @@ -1020,6 +1033,24 @@ options.registry.gallery = options.Class.extend({ this.mode(null, $activeMode.data('mode'), $activeMode); }, /** + * Get the image target's layout mode (slideshow, masonry, grid or nomode). + * + * @returns {String('slideshow'|'masonry'|'grid'|'nomode')} + */ + getMode: function () { + var mode = 'slideshow'; + if (this.$target.hasClass('o_masonry')) { + mode = 'masonry'; + } + if (this.$target.hasClass('o_grid')) { + mode = 'grid'; + } + if (this.$target.hasClass('o_nomode')) { + mode = 'nomode'; + } + return mode; + }, + /** * Displays the images with the "grid" layout. */ grid: function () { @@ -1094,6 +1125,7 @@ options.registry.gallery = options.Class.extend({ this.$target .removeClass('o_nomode o_masonry o_grid o_slideshow') .addClass('o_' + value); + this.trigger_up('cover_update'); }, /** * Displays the images with the standard layout: floating images. diff --git a/addons/website/static/src/js/editor/editor.js b/addons/website/static/src/js/editor/widget_link.js index 12dc17daba6..9dd329f14e6 100644 --- a/addons/website/static/src/js/editor/editor.js +++ b/addons/website/static/src/js/editor/widget_link.js @@ -1,7 +1,7 @@ -odoo.define('website.editor', function (require) { +odoo.define('website.editor.link', function (require) { 'use strict'; -var weWidgets = require('web_editor.widget'); +var weWidgets = require('wysiwyg.widgets'); var wUtils = require('website.utils'); weWidgets.LinkDialog.include({ diff --git a/addons/website/static/src/js/editor/wysiwyg_multizone.js b/addons/website/static/src/js/editor/wysiwyg_multizone.js new file mode 100644 index 00000000000..1441a7ce3e3 --- /dev/null +++ b/addons/website/static/src/js/editor/wysiwyg_multizone.js @@ -0,0 +1,582 @@ +odoo.define('web_editor.wysiwyg.multizone', function (require) { +'use strict'; +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var DropzonePlugin = require('web_editor.wysiwyg.plugin.dropzone'); +var HelperPlugin = require('web_editor.wysiwyg.plugin.helper'); +var TextPlugin = require('web_editor.wysiwyg.plugin.text'); +var HistoryPlugin = require('web_editor.wysiwyg.plugin.history'); +var Wysiwyg = require('web_editor.wysiwyg'); + +var _t = core._t; + +HistoryPlugin.include({ + /** + * @override + */ + applySnapshot: function () { + this.trigger_up('content_will_be_destroyed', { + $target: this.$editable, + }); + this._super.apply(this, arguments); + this.trigger_up('content_was_recreated', { + $target: this.$editable, + }); + $('.oe_overlay').remove(); + $('.note-control-selection').hide(); + this.$editable.trigger('content_changed'); + }, +}); + +HelperPlugin.include({ + /** + * Returns true if range is on or within a field that is not of type 'html'. + * + * @returns {Boolean} + */ + isOnNonHTMLField: function () { + var range = this.context.invoke('editor.createRange'); + return !!$.summernote.dom.ancestor(range.sc, function (node) { + return $(node).data('oe-type') && $(node).data('oe-type') !== 'html'; + }); + }, +}); + +TextPlugin.include({ + /** + * Paste text only if on non-HTML field. + * + * @override + */ + pasteNodes: function (nodes, textOnly) { + textOnly = textOnly || this.context.invoke('HelperPlugin.isOnNonHTMLField'); + this._super.apply(this, [nodes, textOnly]); + }, +}); + +DropzonePlugin.include({ + /** + * Prevent dropping images in non-HTML fields. + * + * @override + */ + _canDropHere: function (dataTransfer) { + if (this.context.invoke('HelperPlugin.isOnNonHTMLField') && dataTransfer.files.length) { + return false; + } + return this._super(); + }, +}); + + + + + +/** + * HtmlEditor + * Intended to edit HTML content. This widget uses the Wysiwyg editor + * improved by odoo. + * + * class editable: o_editable + * class non editable: o_not_editable + * + */ +var WysiwygMultizone = Wysiwyg.extend({ + events: _.extend({}, Wysiwyg.prototype.events, { + 'keyup *': function (ev) { + if ((ev.keyCode === 8 || ev.keyCode === 46)) { + var $target = $(ev.target).closest('.o_editable'); + if (!$target.is(':has(*:not(p):not(br))') && !$target.text().match(/\S/)) { + $target.empty(); + } + } + if (ev.key.length === 1) { + this._onChange(); + } + }, + 'click .note-editable': function (ev) { + ev.preventDefault(); + }, + 'submit .note-editable form .btn': function (ev) { + ev.preventDefault(); // Disable form submition in editable mode + }, + '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(); + } + }, + }), + custom_events: _.extend({}, Wysiwyg.prototype.custom_events, { + activate_snippet: '_onActivateSnippet', + drop_images: '_onDropImages', + }), + /** + * @override + * @param {Object} options.context - the context to use for the saving rpc + * @param {boolean} [options.withLang=false] + * false if the lang must be omitted in the context (saving "master" + * page element) + */ + init: function (parent, options) { + options = options || {}; + options.addDropSelector = ':o_editable'; + this.savingMutex = new concurrency.Mutex(); + this._super(parent, options); + }, + /** + * Prevent some default features for the editable area. + * + * @override + */ + start: function () { + var self = this; + return this._super().then(function () { + // Unload preserve + var flag = false; + window.onbeforeunload = function (event) { + if (self.isDirty() && !flag) { + flag = true; + _.defer(function () { + flag = false; + }); + return _t('Changes you made may not be saved.'); + } + }; + // firefox & IE fix + try { + document.execCommand('enableObjectResizing', false, false); + document.execCommand('enableInlineTableEditing', false, false); + document.execCommand('2D-position', false, false); + } 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; + }); + // BOOTSTRAP preserve + self.init_bootstrap_carousel = $.fn.carousel; + $.fn.carousel = function () { + var res = self.init_bootstrap_carousel.apply(this, arguments); + // off bootstrap keydown event to remove event.preventDefault() + // and allow to change cursor position + $(this).off('keydown.bs.carousel'); + return res; + }; + self.$('.dropdown-toggle').dropdown(); + self.$el + .tooltip({ + selector: '[data-oe-readonly]', + container: 'body', + trigger: 'hover', + delay: { + 'show': 1000, + 'hide': 100, + }, + placement: 'bottom', + title: _t("Readonly field") + }) + .on('click', function () { + $(this).tooltip('hide'); + }); + $('body').addClass('editor_enable'); + $('.note-editor, .note-popover').filter('[data-wysiwyg-id="' + self.id + '"]').addClass('wysiwyg_multizone'); + $('.note-editable .note-editor, .note-editable .note-editable').attr('contenteditable', false); + + self._summernote.isDisabled = function () { + return false; + }; + + self.$('.note-editable').addClass('o_not_editable').attr('contenteditable', false); + self._getEditableArea().attr('contenteditable', true); + self.$('[data-oe-readonly]').addClass('o_not_editable').attr('contenteditable', false); + self.$('.oe_structure').attr('contenteditable', false).addClass('o_fake_not_editable'); + self.$('[data-oe-field][data-oe-type="image"]').attr('contenteditable', false).addClass('o_fake_not_editable'); + self.$('[data-oe-field]:not([contenteditable])').attr('contenteditable', true).addClass('o_fake_editable'); + }); + }, + /** + * @override + */ + destroy: function () { + this._super(); + this.$target.css('display', ''); + this.$target.find('[data-old-id]').add(this.$target).each(function () { + var $node = $(this); + $node.attr('id', $node.attr('data-old-id')).removeAttr('data-old-id'); + }); + $('body').removeClass('editor_enable'); + window.onbeforeunload = null; + $.fn.carousel = this.init_bootstrap_carousel; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {Boolean} + */ + isDirty: function () { + return !!this._getEditableArea().filter('.o_dirty').length; + }, + /** + * @override + * @returns {$.Promise} resolve with true if the content was dirty + */ + save: function () { + var isDirty = this.isDirty(); + if (isDirty) { + this.savingMutex.exec(this._saveCroppedImages.bind(this)); + } + var _super = this._super.bind(this); + return this.savingMutex.def.then(function () { + return _super().then(function (_isDirty, html) { + this._summernote.layoutInfo.editable.html(html); + + var $editable = this._getEditableArea(); + var $areaDirty = $editable.filter('.o_dirty'); + if (!$areaDirty.length) { + return false; + } + $areaDirty.each(function (index, editable) { + this.savingMutex.exec(this._saveEditable.bind(this, editable)); + }.bind(this)); + return this.savingMutex.def.then(function () { + return true; + }); + }.bind(this)); + }.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return true if the editor is displaying the popover. + * + * @override + * @returns {Boolean} + */ + _isDisplayingPopover: function (node) { + return this._super(node) && $(node).parent().closest('[data-oe-model="ir.ui.view"], [data-oe-type="html"]').length; + }, + /** + * @override + * @returns {Object} the summernote configuration + */ + _editorOptions: function () { + var options = this._super(); + options.toolbar[8] = ['view', ['help']]; + + // remove blockquote (it's a snippet) + var blockquote = options.styleTags.indexOf('blockquote'); + if (blockquote !== -1) options.styleTags.splice(blockquote, 1); + + options.popover.image[4] = ['editImage', ['cropImage', 'transform']]; + return _.extend(options, { + styleWithSpan: false, + followingToolbar: false, + }); + }, + /** + * Escape internal text nodes for XML storage. + * + * @private + * @param {jQuery} $el + */ + _escapeElements: function ($el) { + var toEscape = $el.find('*').addBack(); + toEscape = toEscape.not(toEscape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack()); + toEscape.contents().each(function () { + if (this.nodeType === 3) { + this.nodeValue = $('<div />').text(this.nodeValue).html(); + } + }); + }, + /** + * Gets jQuery cloned element with clean for XML storage. + * + * @private + * @param {jQuery} $el + * @returns {jQuery} + */ + _getCleanedHtml: function (editable) { + var $el = $(editable).clone().removeClass('o_editable o_dirty'); + this._escapeElements($el); + return $el; + }, + /** + * Return an object describing the linked record. + * + * @override + * @param {Object} options + * @returns {Object} {res_id, res_model, xpath} + */ + _getRecordInfo: function (options) { + options = options || {}; + var $editable = $(options.target).closest(this._getEditableArea()); + if (!$editable.length) { + $editable = $(this._getFocusedEditable()); + } + var data = this._super(); + var res_id = $editable.data('oe-id'); + var res_model = $editable.data('oe-model'); + if (!$editable.data('oe-model')) { + var object = $('html').data('main-object'); + res_model = object.split('(')[0]; + res_id = +object.split('(')[1].split(',')[0]; + } + var xpath = $editable.data('oe-xpath'); + + if (options.type === 'media' && (res_model === 'website.page' || res_model === 'ir.ui.view')) { + res_id = 0; + res_model = 'ir.ui.view'; + xpath = null; + } + + return _.extend(data, { + res_id: res_id, + res_model: res_model, + xpath: xpath, + }); + }, + /** + * Return the focused editable area. + * + * @private + * @returns {Node} + */ + _getFocusedEditable: function () { + var $focusedNode = $(this._focusedNode); + var $editableArea = this._getEditableArea(); + return $focusedNode.closest($editableArea)[0] || + $focusedNode.find($editableArea)[0]; + }, + /** + * Return the editable areas. + * + * @private + * @returns {JQuery} + */ + _getEditableArea: function () { + if (!this._summernote) { + return $(); + } + return $(this.selectorEditableArea, this._summernote.layoutInfo.editable); + }, + + /** + * @override + * @returns {Promise} + */ + _loadInstance: function () { + return this._super().then(function () { + var $target = this.$target; + var id = $target.attr('id'); + var className = $target.attr('class'); + $target.off('.WysiwygFrontend'); + this.$target.find('[id]').add(this.$target).each(function () { + var $node = $(this); + $node.attr('data-old-id', $node.attr('id')).removeAttr('id'); + }); + this.$('.note-editable:first').attr('id', id).addClass(className); + this.selectorEditableArea = '.o_editable'; + }.bind(this)); + }, + /** + * @private + * @returns {Promise} + */ + _saveEditable: function (editable) { + var self = this; + var recordInfo = this._getRecordInfo({target: editable}); + var outerHTML = this._getCleanedHtml(editable).prop('outerHTML'); + var def = this._saveElement(outerHTML, recordInfo, editable); + def.done(function () { + self.trigger_up('saved', recordInfo); + }).fail(function () { + self.trigger_up('canceled', recordInfo); + }); + return def; + }, + /** + * @private + * @returns {$.Promise} + */ + _saveCroppedImages: function () { + var self = this; + var $area = $(this.selectorEditableArea, this.$target); + var defs = $area.find('.o_cropped_img_to_save').map(function () { + var $croppedImg = $(this); + $croppedImg.removeClass('o_cropped_img_to_save'); + var resModel = $croppedImg.data('crop:resModel'); + var resID = $croppedImg.data('crop:resID'); + var cropID = $croppedImg.data('crop:id'); + var mimetype = $croppedImg.data('crop:mimetype'); + var originalSrc = $croppedImg.data('crop:originalSrc'); + var datas = $croppedImg.attr('src').split(',')[1]; + if (!cropID) { + var name = originalSrc + '.crop'; + return self._rpc({ + model: 'ir.attachment', + method: 'create', + args: [{ + res_model: resModel, + res_id: resID, + name: name, + datas_fname: name, + datas: datas, + mimetype: mimetype, + url: originalSrc, // To save the original image that was cropped + }], + }).then(function (attachmentID) { + return self._rpc({ + model: 'ir.attachment', + method: 'generate_access_token', + args: [[attachmentID]], + }).then(function (access_token) { + $croppedImg.attr('src', '/web/image/' + attachmentID + '?access_token=' + access_token[0]); + }); + }); + } else { + return self._rpc({ + model: 'ir.attachment', + method: 'write', + args: [[cropID], {datas: datas}], + }); + } + }).get(); + return $.when.apply($, defs); + }, + /** + * Saves one (dirty) element of the page. + * + * @private + * @param {string} outerHTML + * @param {Object} recordInfo + * @returns {Promise} + */ + _saveElement: function (outerHTML, recordInfo) { + return this._rpc({ + model: 'ir.ui.view', + method: 'save', + args: [ + recordInfo.res_id, + outerHTML, + recordInfo.xpath, + ], + kwargs: { + context: recordInfo.context, + }, + }); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @override + * @param {OdooEvent} ev + */ + _onActivateSnippet: function (ev) { + if (!$.contains(ev.data[0], this._focusedNode)) { + this._focusedNode = ev.data[0]; + } + ev.data.closest('.oe_structure > *:not(.o_fake_editable)').addClass('o_fake_editable').attr('contenteditable', true); + }, + /** + * @override + */ + _onChange: function () { + var editable = this._getFocusedEditable(); + $(editable).addClass('o_dirty'); + this._super.apply(this, arguments); + }, + /** + * @override + * @param {OdooEvent} ev + */ + _onContentChange: function (ev) { + this._focusedNode = ev.target; + this._super.apply(this, arguments); + }, + /** + * Triggered when the user begin to drop iamges in the editor. + * + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {Node[]} ev.data.spinners + * @param {$.Promise} ev.data.promises + */ + _onDropImages: function (ev) { + if (!this.snippets) { + return; + } + var gallerySelector = this.snippets.$('[data-js="gallery"]').data('selector'); + var $gallery = this.snippets.$activeSnippet && this.snippets.$activeSnippet.closest(gallerySelector) || $(); + + if (!$gallery.length && ev.data.spinners.length >= 2) { + $gallery = this.snippets.$snippets.filter(':has(' + gallerySelector + ')').first(); + var $drag = $gallery.find('.oe_snippet_thumbnail'); + var pos = $drag.offset(); + + $gallery.find('section').attr('id', 'onDropImagesGallery'); + + $drag.trigger($.Event("mousedown", { + which: 1, + pageX: pos.left, + pageY: pos.top + })); + + pos = $(ev.data.spinners[0]).offset(); + $drag.trigger($.Event("mousemove", { + which: 1, + pageX: pos.left, + pageY: pos.top + })); + $drag.trigger($.Event("mouseup", { + which: 1, + pageX: pos.left, + pageY: pos.top + })); + + $gallery = $('#wrapwrap #onDropImagesGallery').removeAttr('id'); + } + + if (!$gallery.length) { + return; + } + + $(ev.data.spinners).remove(); + + _.each(ev.data.promises, function (promise) { + promise.then(function (image) { + $gallery.find('.container:first').append(image); + }); + }); + }, + /** + * @override + */ + _onFocusnode: function (node) { + this._focusedNode = node; + this._super.apply(this, arguments); + }, +}); + +return WysiwygMultizone; +}); diff --git a/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js b/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js new file mode 100644 index 00000000000..5f68ff09e0f --- /dev/null +++ b/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js @@ -0,0 +1,289 @@ +odoo.define('web_editor.wysiwyg.multizone.translate', function (require) { +'use strict'; + +var core = require('web.core'); +var webDialog = require('web.Dialog'); +var Dialog = require('wysiwyg.widgets.Dialog'); +var WysiwygMultizone = require('web_editor.wysiwyg.multizone'); + +var _t = core._t; + + +var AttributeTranslateDialog = Dialog.extend({ + /** + * @constructor + */ + init: function (parent, options, node) { + this._super(parent, _.extend({ + title: _t("Translate Attribute"), + buttons: [ + {text: _t("Close"), classes: 'btn-primary', click: this.save} + ], + }, options || {})); + this.translation = $(node).data('translation'); + }, + /** + * @override + */ + start: function () { + var $group = $('<div/>', {class: 'form-group'}).appendTo(this.$el); + _.each(this.translation, function (node, attr) { + var $node = $(node); + var $label = $('<label class="col-form-label"></label>').text(attr); + var $input = $('<input class="form-control"/>').val($node.html()); + $input.on('change keyup', function () { + var value = $input.val(); + $node.html(value).trigger('change', node); + $node.data('$node').attr($node.data('attribute'), value).trigger('translate'); + $node.trigger('change'); + }); + $group.append($label).append($input); + }); + return this._super.apply(this, arguments); + } +}); + +var WysiwygTranslate = WysiwygMultizone.extend({ + /** + * @override + * @param {string} options.lang + */ + init: function (parent, options) { + this.lang = options.lang; + options.recordInfo = _.defaults({ + context: {lang: this.lang} + }, options.recordInfo, options); + this._super.apply(this, arguments); + }, + /** + * @override + */ + start: function () { + var self = this; + return this._super().then(function () { + var attrs = ['placeholder', 'title', 'alt']; + _.each(attrs, function (attr) { + self._getEditableArea().filter('[' + attr + '*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img').each(function () { + var $node = $(this); + var translation = $node.data('translation') || {}; + var trans = $node.attr(attr); + var match = trans.match(/<span [^>]*data-oe-translation-id="([0-9]+)"[^>]*>(.*)<\/span>/); + var $trans = $(trans).addClass('d-none o_editable o_editable_translatable_attribute').appendTo('body'); + $trans.data('$node', $node).data('attribute', attr); + + translation[attr] = $trans[0]; + $node.attr(attr, match[2]); + + var select2 = $node.data('select2'); + if (select2) { + select2.blur(); + $node.on('translate', function () { + select2.blur(); + }); + $node = select2.container.find('input'); + } + $node.addClass('o_translatable_attribute').data('translation', translation); + }); + }); + + self.translations = []; + self.$editables_attr = self._getEditableArea().filter('.o_translatable_attribute'); + self.$editables_attribute = $('.o_editable_translatable_attribute'); + + self.$editables_attribute.on('change', self._onChange.bind(self)); + + self._markTranslatableNodes(); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {Boolean} + */ + isDirty: function () { + return this._super() || this.$editables_attribute.hasClass('o_dirty'); + }, + /** + * @override + * @param {Node} node + * @returns {Boolean} + */ + isUnbreakableNode: function (node) { + return !!this._super(node) || !!$(node).data('oe-readonly'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return the editable area. + * + * @override + * @returns {JQuery} + */ + _getEditableArea: function () { + var $editables = this._super(); + return $editables.add(this.$editables_attribute); + }, + /** + * Return an object describing the linked record. + * + * @override + * @param {Object} options + * @returns {Object} {res_id, res_model, xpath} + */ + _getRecordInfo: function (options) { + options = options || {}; + var recordInfo = this._super(options); + var $editable = $(options.target).closest(this._getEditableArea()); + if (!$editable.length) { + $editable = $(this._getFocusedEditable()); + } + recordInfo.context.lang = this.lang; + recordInfo.translation_id = $editable.data('oe-translation-id')|0; + return recordInfo; + }, + /** + * Saves one (dirty) element of the page. + * + * @override + * @param {string} outerHTML + * @param {Object} recordInfo + * @param {DOM} editable + * @returns {Promise} + */ + _saveElement: function (outerHTML, recordInfo, editable) { + if (!recordInfo.translation_id) { + return this._super(outerHTML, recordInfo); + } + return this._rpc({ + model: 'ir.translation', + method: 'save_html', + args: [ + [recordInfo.translation_id], + $(editable).html(), + ], + kwargs: { + context: recordInfo.context, + }, + }).fail(function (error) { + console.error(error.data.message); + webDialog.alert(null, error.data.message); + }); + }, + /** + * @override + * @returns {Object} the summernote configuration + */ + _editorOptions: function () { + var options = this._super(); + options.toolbar = [ + // todo: hide this feature for field (data-oe-model) + ['font', ['bold', 'italic', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['color', ['color']], + // keep every time + ['history', ['undo', 'redo']], + ]; + return options; + }, + /** + * @override + * @returns {Object} modules list to load + */ + _getPlugins: function () { + var plugins = this._super(); + return _.omit(plugins, 'linkPopover', 'ImagePopover', 'MediaPlugin', 'ImagePlugin', 'VideoPlugin', 'IconPlugin', 'DocumentPlugin', 'tablePopover'); + }, + /** + * Called when text is edited -> make sure text is not messed up and mark + * the element as dirty. + * + * @override + * @param {Jquery Event} [ev] + */ + _onChange: function (ev) { + var $node = $(ev && ev.target || this._getFocusedEditable()); + if (!$node.length) { + return; + } + $node.find('p').each(function () { // remove <p/> 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]+/, ' ')); + }, + /** + * Returns a translation object. + * + * @private + * @param {Node} node + * @returns {Object} + */ + _getTranlationObject: function (node) { + var $node = $(node); + var id = +$node.data('oe-translation-id'); + if (!id) { + id = $node.data('oe-model')+','+$node.data('oe-id')+','+$node.data('oe-field'); + } + var trans = _.find(this.translations, function (trans) { + return trans.id === id; + }); + if (!trans) { + this.translations.push(trans = {'id': id}); + } + return trans; + }, + /** + * @private + */ + _markTranslatableNodes: function () { + var self = this; + this._getEditableArea().each(function () { + var $node = $(this); + var trans = self._getTranlationObject(this); + trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' '); + }); + this._getEditableArea().prependEvent('click.translator', function (ev) { + if (ev.ctrlKey || !$(ev.target).is(':o_editable')) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + }); + + // attributes + + this.$editables_attr.each(function () { + var $node = $(this); + var translation = $node.data('translation'); + _.each(translation, function (node, attr) { + 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.$editables_attr.prependEvent('mousedown.translator click.translator mouseup.translator', function (ev) { + if (ev.ctrlKey) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + if (ev.type !== 'mousedown') { + return; + } + + new AttributeTranslateDialog(self, {}, ev.target).open(); + }); + }, +}); + +return WysiwygTranslate; +}); diff --git a/addons/website/static/src/js/menu/content.js b/addons/website/static/src/js/menu/content.js index cf7041883db..92281f26886 100644 --- a/addons/website/static/src/js/menu/content.js +++ b/addons/website/static/src/js/menu/content.js @@ -6,7 +6,7 @@ var core = require('web.core'); var Dialog = require('web.Dialog'); var time = require('web.time'); var weContext = require('web_editor.context'); -var weWidgets = require('web_editor.widget'); +var weWidgets = require('wysiwyg.widgets'); var websiteNavbarData = require('website.navbar'); var websiteRootData = require('website.WebsiteRoot'); var Widget = require('web.Widget'); @@ -364,13 +364,14 @@ var MenuEntryDialog = weWidgets.LinkDialog.extend({ /** * @constructor */ - init: function (parent, options, editor, data) { + init: function (parent, options, data) { data.text = data.name || ''; data.isNewWindow = data.new_window; this.data = data; + this._super(parent, _.extend({}, { title: _t("Create Menu"), - }, options || {}), editor, data); + }, options || {}), data); }, /** * @override @@ -570,7 +571,7 @@ var EditMenuDialog = weWidgets.Dialog.extend({ */ _onAddMenuButtonClick: function () { var self = this; - var dialog = new MenuEntryDialog(this, {}, undefined, {}); + var dialog = new MenuEntryDialog(this, {}, {}); dialog.on('save', this, function (link) { var new_menu = { id: _.uniqueId('new-'), @@ -611,7 +612,7 @@ var EditMenuDialog = weWidgets.Dialog.extend({ var menu_id = $(ev.currentTarget).closest('[data-menu-id]').data('menu-id'); var menu = self.flat[menu_id]; if (menu) { - var dialog = new MenuEntryDialog(this, {}, undefined, menu); + var dialog = new MenuEntryDialog(this, {}, menu); dialog.on('save', this, function (link) { var id = link.id; var menu_obj = self.flat[id]; diff --git a/addons/website/static/src/js/menu/customize.js b/addons/website/static/src/js/menu/customize.js index b931e938cc6..6b54cf10416 100644 --- a/addons/website/static/src/js/menu/customize.js +++ b/addons/website/static/src/js/menu/customize.js @@ -87,7 +87,7 @@ var CustomizeMenu = Widget.extend({ $menu.append('<li class="dropdown-header">' + currentGroup + '</li>'); } var $a = $('<a/>', {href: '#', class: 'dropdown-item', 'data-view-id': item.id, role: 'menuitem'}) - .append(qweb.render('web_editor.components.switch', {id: 'switch-' + item.id, label: item.name})); + .append(qweb.render('website.components.switch', {id: 'switch-' + item.id, label: item.name})); $a.find('input').prop('checked', !!item.active); $menu.append($a); }); diff --git a/addons/website/static/src/js/menu/edit.js b/addons/website/static/src/js/menu/edit.js index d2ffa32268a..98322d9e092 100644 --- a/addons/website/static/src/js/menu/edit.js +++ b/addons/website/static/src/js/menu/edit.js @@ -3,7 +3,7 @@ odoo.define('website.editMenu', function (require) { var core = require('web.core'); var weContext = require('web_editor.context'); -var editor = require('web_editor.editor'); +var EditorMenu = require('website.editor.menu'); var websiteNavbarData = require('website.navbar'); var _t = core._t; @@ -22,6 +22,8 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ content_was_recreated: '_onContentWasRecreated', snippet_cloned: '_onSnippetCloned', snippet_dropped: '_onSnippetDropped', + edition_will_stopped: '_onEditionWillStop', + edition_was_stopped: '_onEditionWasStopped', }), /** @@ -39,22 +41,17 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ start: function () { var def = this._super.apply(this, arguments); - // If we auto start the editor, do not show a welcome message - if (this._editorAutoStart) { - this._startEditMode(); - return def; - } - // Check that the page is empty - var $wrap = $('#wrapwrap.homepage #wrap'); // TODO find this element another way - if (!$wrap.length || $wrap.html().trim() !== '') { - return def; - } + var $wrap = this._targetForEdition().find('#wrap'); + this.$wrap = $wrap; - // If readonly empty page, show the welcome message - this.$welcomeMessage = $(core.qweb.render('website.homepage_editor_welcome_message')); - this.$welcomeMessage.css('min-height', $wrap.parent('main').height() - ($wrap.outerHeight(true) - $wrap.height())); - $wrap.empty().append(this.$welcomeMessage); + if ($wrap.length && $wrap.html().trim() === '') { + // If readonly empty page, show the welcome message + this.$welcomeMessage = $(core.qweb.render('website.homepage_editor_welcome_message')); + this.$welcomeMessage.addClass('o_homepage_editor_welcome_message'); + this.$welcomeMessage.css('min-height', $wrap.parent('main').height() - ($wrap.outerHeight(true) - $wrap.height())); + $wrap.empty().append(this.$welcomeMessage); + } setTimeout(function () { if ($('.o_tooltip.o_animated').length) { @@ -62,6 +59,10 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ } }, 1000); // ugly hack to wait that tooltip is loaded + // If we auto start the editor, do not show a welcome message + if (this._editorAutoStart) { + return $.when(def, this._startEditMode()); + } return def; }, @@ -78,11 +79,17 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ */ _startEditMode: function () { var self = this; - return (new (editor.Class)(this)).prependTo(document.body).then(function () { + this.trigger_up('animation_stop_demand', { + $target: this._targetForEdition(), + }); + if (this.$welcomeMessage) { + this.$welcomeMessage.detach(); // detach from the readonly rendering before the clone by summernote + } + return new EditorMenu(this).prependTo(document.body).then(function () { if (self.$welcomeMessage) { - self.$welcomeMessage.remove(); + self.$wrap.append(self.$welcomeMessage); // reappend if the user cancel the edition } - var $wrapwrap = $('#wrapwrap'); // TODO find this element another way + var $wrapwrap = self._targetForEdition(); var $htmlEditable = $wrapwrap.find('.oe_structure.oe_empty, [data-oe-type="html"]').not('[data-editor-message]'); $htmlEditable.attr('data-editor-message', _t('DRAG BUILDING BLOCKS HERE')); var def = $.Deferred(); @@ -109,6 +116,21 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ _onSave: function () {}, //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the target for edition. + * + * @private + * @returns {JQuery} + */ + _targetForEdition: function () { + // in edit mode, we have .note-editable className + return $('#wrapwrap:not(.note-editable), #wrapwrap.note-editable'); + }, + + //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- @@ -125,7 +147,7 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ }); }, /** - * Called when content will be recreated in the page. Notifies the + * Called when content was recreated in the page. Notifies the * WebsiteRoot that is should start the animations. * * @private @@ -138,6 +160,33 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ }); }, /** + * Called when edition will stop. Notifies the + * WebsiteRoot that is should stop the animations. + * + * @private + * @param {OdooEvent} ev + */ + _onEditionWillStop: function (ev) { + var $target = this._targetForEdition(); + $target.find('[data-editor-message]').removeAttr('data-editor-message'); + this.trigger_up('animation_stop_demand', { + $target: $target, + }); + }, + /** + * Called when edition was stopped. Notifies the + * WebsiteRoot that is should start the animations. + * + * @private + * @param {OdooEvent} ev + */ + _onEditionWasStopped: function (ev) { + var $target = this._targetForEdition(); + this.trigger_up('animation_start_demand', { + $target: $target, + }); + }, + /** * Called when a snippet is cloned in the page. Notifies the WebsiteRoot * that is should start the animations for this snippet. * diff --git a/addons/website/static/src/js/menu/navbar.js b/addons/website/static/src/js/menu/navbar.js index 8b7c11f58a7..aa1646b2eaa 100644 --- a/addons/website/static/src/js/menu/navbar.js +++ b/addons/website/static/src/js/menu/navbar.js @@ -17,6 +17,7 @@ var WebsiteNavbar = rootWidget.RootWidget.extend({ custom_events: _.extend({}, rootWidget.RootWidget.prototype.custom_events || {}, { action_demand: '_onActionDemand', edit_mode: '_onEditMode', + readonly_mode: '_onReadonlyMode', ready_to_save: '_onSave', }), @@ -141,11 +142,8 @@ var WebsiteNavbar = rootWidget.RootWidget.extend({ * @private */ _onEditMode: function () { - var self = this; this.$el.addClass('editing_mode'); - _.delay(function () { - self.do_hide(); - }, 800); + this.do_hide(); }, /** * Called when a submenu is hovered -> automatically opens it if another @@ -171,6 +169,15 @@ var WebsiteNavbar = rootWidget.RootWidget.extend({ this.$el.parent().toggleClass('o_mobile_menu_opened'); }, /** + * Called in response to edit mode activation -> hides the navbar. + * + * @private + */ + _onReadonlyMode: function () { + this.$el.removeClass('editing_mode'); + this.do_show(); + }, + /** * Called in response to edit mode saving -> checks if action-capable * children have something to save. * diff --git a/addons/website/static/src/js/menu/new_content.js b/addons/website/static/src/js/menu/new_content.js index 490e4f75516..0e1a10ebc80 100644 --- a/addons/website/static/src/js/menu/new_content.js +++ b/addons/website/static/src/js/menu/new_content.js @@ -83,7 +83,7 @@ var NewContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ var $add = $('<div/>', {'class': 'form-group mb0'}) .append($('<span/>', {'class': 'offset-md-3 col-md-9 text-left'}) - .append(qweb.render('web_editor.components.switch', {id: 'switch_addTo_menu', label: _t("Add to menu")}))); + .append(qweb.render('website.components.switch', {id: 'switch_addTo_menu', label: _t("Add to menu")}))); $add.find('input').prop('checked', true); $group.after($add); } diff --git a/addons/website/static/src/js/menu/seo.js b/addons/website/static/src/js/menu/seo.js index a0a2d0f6b60..706f219761b 100644 --- a/addons/website/static/src/js/menu/seo.js +++ b/addons/website/static/src/js/menu/seo.js @@ -8,7 +8,7 @@ var mixins = require('web.mixins'); var rpc = require('web.rpc'); var Widget = require('web.Widget'); var weContext = require('web_editor.context'); -var weWidgets = require('web_editor.widget'); +var weWidgets = require('wysiwyg.widgets'); var websiteNavbarData = require('website.navbar'); var _t = core._t; diff --git a/addons/website/static/src/js/menu/translate.js b/addons/website/static/src/js/menu/translate.js index 34924a83188..9c6c6312a13 100644 --- a/addons/website/static/src/js/menu/translate.js +++ b/addons/website/static/src/js/menu/translate.js @@ -3,7 +3,7 @@ odoo.define('website.translateMenu', function (require) { var utils = require('web.utils'); var weContext = require('web_editor.context'); -var translate = require('web_editor.translate'); +var TranslatorMenu = require('website.editor.menu.translate'); var websiteNavbarData = require('website.navbar'); var ctx = weContext.getExtra(); @@ -67,7 +67,7 @@ var TranslatePageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ window.location.search += '&edit_translations'; return $.Deferred(); } - var translator = new (translate.Class)(this, $('#wrapwrap')); + var translator = new TranslatorMenu(this); return translator.prependTo(document.body); }, }); diff --git a/addons/website/static/src/js/tours/rte.js b/addons/website/static/src/js/tours/rte.js index 46fbbff3fce..bdc676f4b33 100644 --- a/addons/website/static/src/js/tours/rte.js +++ b/addons/website/static/src/js/tours/rte.js @@ -1,12 +1,18 @@ odoo.define('website.tour.rte', function (require) { 'use strict'; +var ajax = require('web.ajax'); +var session = require('web.session'); var tour = require('web_tour.tour'); -var base = require('web_editor.base'); +var Wysiwyg = require('web_editor.wysiwyg'); + +var domReady = $.Deferred(); +$(domReady.resolve.bind(domReady)); +var ready = $.when(domReady, session.is_bound, ajax.loadXML()); tour.register('rte_translator', { test: true, - wait_for: base.ready(), + wait_for: ready, }, [{ content: "click on Add a language", trigger: '.js_language_selector a:has(i.fa)', @@ -59,10 +65,10 @@ tour.register('rte_translator', { content: "save", trigger: 'button[data-action=save]', extra_trigger: '#wrap p:first b', - }, { content : "click language dropdown", trigger : '.js_language_selector .dropdown-toggle', + extra_trigger: 'body:not(.o_wait_reload):not(:has(.note-editor))', }, { content: "click on french version", trigger: '.js_language_selector a[data-lang="fr_BE"]', @@ -81,14 +87,17 @@ tour.register('rte_translator', { trigger: '#wrap p font:first', run: function (action_helper) { action_helper.text('translated french text'); - this.$anchor.trigger('keydown'); + Wysiwyg.setRange(this.$anchor.contents()[0], 22); + this.$anchor.trigger($.Event( "keyup", {key: '_', keyCode: 95})); }, }, { content: "translate text with special char", trigger: '#wrap input + p span:first', run: function (action_helper) { action_helper.click(); - this.$anchor.prepend('<{translated}>').trigger('keydown'); + this.$anchor.prepend('<{translated}>'); + Wysiwyg.setRange(this.$anchor.contents()[0], 0); + this.$anchor.trigger($.Event( "keyup", {key: '_', keyCode: 95})); }, }, { content: "click on input", @@ -125,9 +134,12 @@ tour.register('rte_translator', { trigger : '.js_language_selector .dropdown-toggle', }, { + content: "open language selector", + trigger: '.js_language_selector button:first', + extra_trigger: 'html[lang*="fr"]:not(:has(#wrap p span))', +}, { content: "return to english version", trigger: '.js_language_selector a[data-lang="en_US"]', - extra_trigger: 'html[lang*="fr"]:not(:has(#wrap p span))', }, { content: "edit english version", trigger: 'a[data-action=edit]', @@ -139,27 +151,25 @@ tour.register('rte_translator', { run: function (action_helper) { action_helper.click(); var el = this.$anchor[0]; - var evt = document.createEvent("MouseEvents"); - $.summernote.core.range.create(el.childNodes[2], 6, el.childNodes[2], 13).select(); - evt.initMouseEvent('mouseup', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el); - el.dispatchEvent(evt); + this.$anchor.trigger('mousedown'); + Wysiwyg.setRange(el.childNodes[2], 6, el.childNodes[2], 13); + this.$anchor.trigger('mouseup'); }, }, { content: "underline", - trigger: '.note-air-popover button[data-event="underline"]', + trigger: 'button.note-btn-underline', }, { content: "save new change", trigger: 'button[data-action=save]', - extra_trigger: '#wrap p u', + extra_trigger: '#wrap.o_dirty p u', }, { content : "click language dropdown", trigger : '.js_language_selector .dropdown-toggle', - + extra_trigger: '#wrap p u', }, { content: "return in french", - trigger: 'html[lang="en-US"] .js_language_selector a[data-lang="fr_BE"]', - extra_trigger: 'html[lang*="en"]:not(:has(button[data-action=save]))', + trigger : 'html[lang="en-US"] .js_language_selector .js_change_lang[data-lang="fr_BE"]', }, { content: "check bis: content is translated", trigger: '#wrap p font:first:contains(translated french text)', diff --git a/addons/website/static/src/js/widgets/theme.js b/addons/website/static/src/js/widgets/theme.js index 46a3ddcccf3..2de299b55c0 100644 --- a/addons/website/static/src/js/widgets/theme.js +++ b/addons/website/static/src/js/widgets/theme.js @@ -3,9 +3,10 @@ odoo.define('website.theme', function (require) { var config = require('web.config'); var core = require('web.core'); -var ColorpickerDialog = require('web.colorpicker'); +var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog'); var Dialog = require('web.Dialog'); -var widgets = require('web_editor.widget'); +var weContext = require('web_editor.context'); +var widgets = require('wysiwyg.widgets'); var websiteNavbarData = require('website.navbar'); var _t = core._t; @@ -43,7 +44,7 @@ var ThemeCustomizeDialog = Dialog.extend({ templateDef = this._rpc({ model: 'ir.ui.view', method: 'read_template', - args: ['website.theme_customize'], + args: ['website.theme_customize', weContext.get()], }).then(function (data) { return core.qweb.add_template(data); }); @@ -285,6 +286,7 @@ var ThemeCustomizeDialog = Dialog.extend({ data[1], '#wrapwrap { background-image: url("' + src + '"); }', '//style', + weContext.get(), ], }); }).then(function () { @@ -472,7 +474,7 @@ var ThemeCustomizeDialog = Dialog.extend({ }); var colors = {}; - colors[colorName] = ev.data.cssColor; + colors[colorName] = ev.data.hex; if (colorName === 'alpha') { colors['beta'] = 'null'; colors['gamma'] = 'null'; diff --git a/addons/website/static/src/scss/website.editor.ui.scss b/addons/website/static/src/scss/website.editor.ui.scss new file mode 100644 index 00000000000..00de5fc2e27 --- /dev/null +++ b/addons/website/static/src/scss/website.editor.ui.scss @@ -0,0 +1,812 @@ +// TOP BAR (EDIT) +#web_editor-top-edit { + height: $o-navbar-height; + background-color: $o-we-color-dark; + z-index: $zindex-modal - 9; + position: fixed; + top: 0px; + right: 0px; + width: auto; + white-space: nowrap; + + background-color: rgba(0, 0, 0, 0); + transition: background-color 400ms $o-we-md-ease 0s; + font-family: $o-we-font-family; + + form.navbar-form { + height: $o-navbar-height; + z-index: 1060; + margin: 0; + padding: 0; + @include o-position-absolute($top: -1px, $right: -$o-we-sidebar-width); + transition: right 0.4s $o-we-md-ease 0s; + border-left: 1px solid $o-we-color-divider; + background-color: inherit; + + .btn-group { + height: 100%; + } + + .btn { + height: 100%; + margin: 0; + padding: 10px; + line-height: 1.2; + font-size: 13px; + font-family: $o-we-font-family; + + transition: all 0.3s ease 0s; + border: none; + border-radius: 0; + + .fa { + margin-right: $grid-gutter-width/4; + + &.fa-times { + color: $o-we-color-danger; + } + } + + &.btn-primary { + @include button-variant($o-brand-odoo, $o-brand-odoo); + } + + &.btn-secondary { + @include button-variant($o-we-color-dark, $o-we-color-dark); + + &:hover { + background: rgba(black, 0.5); + } + } + + &:focus, + &:active, + &:focus:active { + outline: none; + } + } + + .dropdown-menu { + left: auto; + right: 0; + } + } +} + +// Translations +.oe_translate_examples li { + margin: 10px; + padding: 4px; +} + +html[lang]>body.editor_enable [data-oe-translation-state] { + background: rgba($o-we-content-to-translate-color, 0.5) !important; + + &[data-oe-translation-state="translated"] { + background: rgba($o-we-translated-content-color, 0.5) !important; + } + + &.o_dirty { + background: rgba($o-we-translated-content-color, 0.25) !important; + } +} + +// NOTE EDITOR + +// SNIPPET PANEL +#oe_snippets { + @include o-w-preserve-btn; + @include o-position-absolute(0, auto, 0, -$o-we-sidebar-width); + width: $o-we-sidebar-width; + + border-right: 1px solid $o-we-color-divider; + transition: left 400ms $o-we-md-ease 0s; + background-image: linear-gradient(45deg, $o-we-color-normal, darken($o-we-color-normal, 10%)); + + #snippets_menu { + color: #999999; + background: $o-we-color-dark; + font-family: $o-we-font-family; + height: $o-navbar-height; + line-height: $o-navbar-height; + } + + #o_scroll { + .o_panel_header { + color: #999999; + } + } +} + +.oe_snippet { + background-color: $o-we-color-normal; + + .oe_snippet_thumbnail { + .oe_snippet_thumbnail_img { + border: 1px solid $o-we-color-normal; + } + + img.oe_snippet_thumbnail_img {} + + .oe_snippet_thumbnail_title { + border: 1px solid $o-we-color-dark; + color: $o-we-color-text-light; + } + + &:hover .oe_snippet_thumbnail_title { + color: $o-we-color-text-lighter; + } + } +} + +// SNIPPET OPTIONS +.colorpicker { + .o_colorpicker_sections { + .note-color-palette>div { + color: $o-we-color-text-light; + } + } + + .o_colorpicker_section { + &:after { + content: ""; + display: table; + clear: both; + } + + >button { + + &:hover, + &.selected { + box-shadow: 0px 0px 2px 2px $o-we-color-light; + } + + &[data-event="foreColor"] { + background-color: $o-we-color-normal; + + &:before { + background-color: rgba(white, 0.3); + } + } + } + } + + .note-palette-title { + color: $o-we-color-text-light; + } + + .palette-reset { + .note-color-reset { + color: desaturate(rgba($o-we-color-danger, 0.6), 40%); + + &:hover { + color: $o-we-color-danger; + } + } + } +} + +// DROPZONES +#wrapwrap .oe_drop_zone { + &.oe_insert { + height: $o-we-dropzone-size; + margin: (-$o-we-dropzone-size/2) 0; + + &:after { + border-bottom: $o-we-dropzone-border; + } + + &.oe_vertical { + width: $o-we-dropzone-size; + margin: 0 (-$o-we-dropzone-size/2); + + &:after { + border-right: $o-we-dropzone-border; + } + } + } +} + +// MANIPULATORS +#oe_manipulators { + + // SNIPPET MANIPULATORS + .oe_overlay { + + // OVERLAY OPTIONS + >.oe_overlay_options { + font-family: $o-we-font-family; + + >.btn-group { + .btn { + width: $o-we-overlay-option-size; + height: $o-we-overlay-option-size; + border: 1px solid $o-we-color-dark; + line-height: #{$o-we-overlay-option-size - 2px}; + + color: $o-we-color-text-light; + background-color: $o-we-color-light; + + &:hover { + background-color: lighten($o-we-color-dark, 10%); + } + + &.oe_snippet_remove { + background-color: $o-we-color-danger; + border-color: darken($o-we-color-danger, 20%); + + &:hover { + background-color: darken($o-we-color-danger, 20%); + } + } + + >.fa { + color: $o-we-color-text-light; + } + } + + // CUSTOMIZE MENU BUTTON + >.oe_options { + .btn { + background-color: $o-we-color-dark; + color: $o-we-color-text-light; + + &:hover, + &:active, + &:focus { + background-color: lighten($o-we-color-dark, 5%); + border-color: lighten($o-we-color-dark, 10%); + } + + &:before, + &:after { + background-color: $o-we-color-text-light; + } + } + + // Open menu + &.show .btn { + // background-color: $o-we-color-light; + } + + // CUSTOMIZE MENU + .dropdown-menu { + border: 1px solid $o-we-color-dark; + background-color: $o-we-color-light; + + .dropdown-item { + color: $o-we-color-text-light; + + &:hover { + background-color: fade-out($o-we-color-dark, 0.5); + } + + &.active { + background-color: fade-out($o-we-color-dark, 0.5); + } + } + + .dropdown-header { + color: wheat; + + &.o_parent_editor_header { + color: wheat; + } + } + + .dropdown-submenu { + &::before { + border-left-color: $o-we-color-text-light; + } + } + } + } + } + } + + &.o_top_cover>.oe_overlay_options { + top: $o-we-handle-border-width; + right: $o-we-handle-border-width; + } + + } +} + +// Animations +@keyframes fadeInDownSmall { + 0% { + opacity: 0; + transform: translate(0, -5px); + } + + 100% { + opacity: 1; + transform: translate(0, 0); + } +} + +@keyframes fadeInOut { + 0% { + opacity: 0; + } + + 25% { + opacity: 1; + } + + 75% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes inputHighlighter { + from { + background: $o-brand-primary; + } + + to { + width: 0; + background: transparent; + } +} + +// INPUTS +.o_switch { + display: flex; + align-items: center; + font-weight: normal; + cursor: pointer; + + >input { + display: none; + + +span { + background-color: $o-we-switch-inactive-color; + box-shadow: inset 0 0 0px 1px darken($o-we-switch-inactive-color, 10%); + border-radius: 100rem; + height: $o-we-switch-size; + width: $o-we-switch-size * 1.8; + margin-right: 0.5em; + display: inline-block; + transition: all 0.3s $o-we-md-ease; + + &:after { + content: ""; + background: $o-we-color-paper; + display: block; + width: $o-we-switch-size - 0.2; + height: $o-we-switch-size - 0.2; + margin-top: 0.1ex; + margin-left: 0.1ex; + border-radius: 100rem; + transition: all 0.3s $o-we-md-ease; + box-shadow: 0 1px 1px darken($o-we-switch-inactive-color, 35%), inset 0 0 0 1px lighten($o-we-switch-inactive-color, 10%); + } + } + + &:checked+span { + box-shadow: none; + background-image: linear-gradient(0deg, $o-we-color-success, darken($o-we-color-success, 10%)); + + &:after { + margin-left: ($o-we-switch-size*1.8 - $o-we-switch-size) + 0.1; + } + } + } + + &.o_switch_danger { + >input { + &:not(:checked)+span { + box-shadow: none; + background-image: linear-gradient(0deg, lighten(theme-color('danger'), 5%), darken(theme-color('danger'), 5%)); + } + } + } +} + +// ACE EDITOR +.o_ace_view_editor { + background: $o-we-ace-color; + color: white; + display: flex; + flex-flow: column nowrap; + opacity: 0.97; + + .o_ace_view_editor_title { + flex: 0 0 auto; + display: flex; + align-items: center; + padding: $grid-gutter-width/4; + + >.o_ace_type_switcher>button::after { + @include o-caret-down; + margin-left: 4px; + } + + >* { + flex: 0 0 auto; + margin: 0 $grid-gutter-width/4; + + &.o_include_option { + display: flex; + align-items: center; + font-size: 11px; + + >.custom-control { + margin-right: $grid-gutter-width/4; + } + } + + &.o_res_list { + flex: 1 1 auto; + min-width: 60px; + } + } + } + + #ace-view-id { + flex: 0 0 auto; + padding: $grid-gutter-width/4 $grid-gutter-width/2; + background-color: lighten($o-we-ace-color, 10%); + } + + #ace-view-editor { + @mixin ace-line-error-mixin { + content: ""; + z-index: 1000; + display: block; + background-color: theme-color('danger'); + opacity: 0.5; + pointer-events: none; + } + + height: 70%; // in case flex is not supported + flex: 1 1 auto; + + .ace_gutter { + cursor: ew-resize; + + .ace_gutter-cell.o_error { + position: relative; + + &::after { + @include o-position-absolute(-100%, 0, -100%, 0); + @include ace-line-error-mixin; + } + } + } + + .ace_resize_bar { + @include o-position-absolute($right: 0); + width: 25px; + height: 100%; + cursor: ew-resize; + } + + .ace_scroller.o_error::after { + @include o-position-absolute(0, auto, 0, 0); + width: 3px; + @include ace-line-error-mixin; + } + } +} + +.o_ace_select2_dropdown { + width: auto !important; + padding-top: 4px; + font-family: monospace !important; + + >.select2-results { + max-height: none; + max-height: 70vh; + + .select2-result-label { + padding-top: 1px; + padding-bottom: 2px; + + >.o_ace_select2_result { + padding: 0; + font-size: 12px; + white-space: nowrap; + } + } + } +} + +// MODALS +body .modal { + + // BACKGROUND IMAGE OPTIONS + .o_bg_img_opt_modal .o_bg_img_opt { + margin: 15px 0; + + .help-control { + @include o-position-absolute(15px, 15px); + } + + .help { + margin: 15px 0; + + .simulator { + position: relative; + float: left; + margin-right: 15px; + width: 120px; + height: 100px; + + .bg { + @include o-position-absolute(0, 0, 0, 0); + border: 1px dotted #888787; + background-image: url(/web/image); + background-position: center center; + background-size: cover; + } + + .el { + @include o-position-absolute(10px, 0, 10px, 0); + border: 1px solid #222; + } + + &.contain_bg { + .bg { + @include o-position-absolute(0, 12px, 20px, 12px); + } + + .el { + @include o-position-absolute(0, 0, 20px, 0); + } + } + } + + +* { + clear: left; + } + } + + .o_bg_img_opt_cover_edition { + @include o-we-preview-box; + padding: 0 25px 25px 25px; + + h6 { + color: white; + font-weight: bold; + } + + .o_bg_img_opt_object { + position: relative; + background: white; + min-height: 10px; + @include o-we-preview-content; + + >img { + cursor: crosshair; + border-top: 1px solid #5A5A5A; + border-bottom: 1px solid #111; + } + + &:hover .o_focus_point:before { + opacity: 0.5; + } + } + + .o_bg_img_opt_ui_info { + animation: fadeInOut 2s ease forwards; + @include o-position-absolute($bottom: 1px, $right: 0); + display: block; + padding: 3px 13px; + background-color: rgba(255, 255, 255, 0.8); + color: #333; + text-align: center; + font-weight: bold; + pointer-events: none; + + span { + font-weight: normal; + } + + .o_x { + margin-right: 10px + } + } + + .grid { + @include o-position-absolute($top: 33.33%, $left: 0); + display: block; + width: 100%; + height: 1px; + background: fade-out(lighten($o-brand-primary, 30%), 0.5); + pointer-events: none; + + &.grid-2 { + top: 66.66% + } + + &.grid-3 { + top: 0; + left: 33.33%; + width: 1px; + height: 100% + } + + &.grid-4 { + top: 0; + left: 66.66%; + width: 1px; + height: 100% + } + } + + .o_focus_point { + @include o-position-absolute($top: 0, $left: 0); + width: 30px; + height: 30px; + border: 2px solid white; + border-radius: 100%; + margin-top: -15px; + margin-left: -15px; + box-shadow: 0 0 1px #333; + pointer-events: none; + + &.o_with_transition { + transition: all 0.2s ease 0s; + } + + &:before { + pointer-events: none; + content: ""; + display: block; + width: 100px; + height: 100px; + margin-top: -37px; + margin-left: -37px; + border: 1px solid #EAEAEA; + background: rgba(247, 76, 76, 0); + border-radius: 100em; + opacity: 1; + box-shadow: 0 0 0 100em rgba(0, 0, 0, 0.33); + transition: opacity 0.2s ease 0s; + } + } + } + } +} + +// editor_enable + +body.editor_enable { + overflow: hidden; + padding-top: 0 !important; + + #web_editor-top-edit { + background-color: $o-we-color-dark; + + form.navbar-form { + right: 0; + } + } + + #wrapwrap { + height: calc(100vh - #{$o-navbar-height}); + padding-top: 0px; + + .btn { + -webkit-user-select: none; + } + } +} + +.wysiwyg_multizone.note-editor { + &>.note-toolbar-wrapper .note-toolbar { + height: $o-navbar-height; + + .btn { + height: $o-navbar-height; + } + } + + &>.note-statusbar { + display: none; + } + + &>.note-editing-area { + overflow: visible; + } +} + +.wysiwyg_multizone.note-popover.popover { + border-width: 0 1px; + + .popover-content .btn { + height: $o-navbar-height; + } +} + +.wysiwyg_multizone.note-popover, +.wysiwyg_multizone.note-editor>.note-toolbar-wrapper .note-toolbar { + background-color: $o-we-color-dark; + + .btn { + background-color: $o-we-color-dark; + color: $o-we-color-text-normal; + border: none; + padding: 0.0625rem 0.3125rem !important; // force this because of themes + + >.fa { + color: $o-we-color-text-normal; + } + + &:hover { + color: $o-we-color-text-light; + + >.fa { + color: $o-we-color-text-light; + } + } + + &.active { + background-color: $o-we-color-dark; + color: $o-we-color-text-light; + + >.fa { + color: $o-we-color-text-light; + } + } + } + + >.btn-group { + margin-top: 0; + + &.show, + .btn-group.show { + .dropdown-toggle { + border-top: 2px solid white; + background-color: $o-we-color-darker; + color: white; + box-shadow: none; + + >.fa, + >span { + color: white; + } + } + } + } + + .dropdown-menu { + margin: 0; + border-radius: 0; + background-color: $o-we-color-darker; + color: $o-we-color-text-normal; + white-space: normal; + + .dropdown-item { + width: 100%; + color: $o-we-color-text-light; + + >.fa { + color: $o-we-color-text-light; + } + + &:hover { + background-color: $o-we-tab-active-bg; + color: white; + + >.fa { + color: white; + } + } + } + } + + .note-para .dropdown-menu { + min-width: 147px; + } + + .note-style .dropdown-item * { + color: $o-we-color-text-light; + } +} diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss index 21ee1c74f73..3b7091b0e3c 100644 --- a/addons/website/static/src/scss/website.scss +++ b/addons/website/static/src/scss/website.scss @@ -194,8 +194,7 @@ font[class*='bg-'] { // Probably outdated // Lists -.o_ul_toggle_self, -.o_ul_toggle_next { +.o_ul_toggle { display: none; } @@ -979,7 +978,8 @@ table.table_desc tr td { // // Header -#wrapwrap .o_header_affix { +.o_header_affix { + display: block; @include o-position-absolute(0, 0, auto, 0); position: fixed; z-index: $zindex-modal - 20; diff --git a/addons/web_editor/static/src/xml/translator.xml b/addons/website/static/src/xml/translator.xml index dbd6c8287f6..d3067f5e699 100644 --- a/addons/web_editor/static/src/xml/translator.xml +++ b/addons/website/static/src/xml/translator.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <templates id="template" xml:space="preserve"> -<div t-name="web_editor.TranslatorInfoDialog"> +<div t-name="website.TranslatorInfoDialog"> <p>You are about to enter the translation mode.</p> <p>Here are the visuals used to help you translate efficiently:</p> <ul class="oe_translate_examples"> diff --git a/addons/website/static/src/xml/website.editor.xml b/addons/website/static/src/xml/website.editor.xml index a9ed7b66601..552c0b26ada 100644 --- a/addons/website/static/src/xml/website.editor.xml +++ b/addons/website/static/src/xml/website.editor.xml @@ -10,6 +10,23 @@ <p class="text-muted">Your current changes will be saved automatically.</p> </div> + <!-- Editor top bar which contains the summernote tools and save/discard buttons --> + <t t-name="website.editorbar"> + <div id="web_editor-top-edit"> + <form class="navbar-form text-muted"> + <button type="button" class="btn btn-secondary" data-action="cancel"><i class="fa fa-times"/> Discard</button> + <button type="button" class="btn btn-primary" data-action="save"><i class="fa fa-floppy-o"/> Save</button> + </form> + </div> + </t> + <!-- Custom checkbox (material-design-like toggle) --> + <t t-name="website.components.switch"> + <label class="o_switch" t-att-for="id"> + <input type="checkbox" t-att-id="id"/> + <span/> + <t t-if="label"><t t-esc="label"/></t> + </label> + </t> <div t-name="website.theme_customize_modal_layout" class="d-flex align-items-start"> <ul class="nav flex-column flex-shrink-0 w-25"/> diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index aca87b0484b..5078c534458 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -44,12 +44,16 @@ <link rel="stylesheet" type="text/scss" href="/website/static/src/scss/website.zoomodoo.scss"/> </xpath> <xpath expr="//script[@src='/web/static/src/js/chrome/public_root_widget.js']" position="replace"> + <script type="text/javascript" src="/website/static/src/js/content/body_manager.js"/> <script type="text/javascript" src="/website/static/src/js/content/website_root.js"/> </xpath> <xpath expr="//script[last()]" position="after"> <script type="text/javascript" src="/website/static/src/js/utils.js"/> <script type="text/javascript" src="/website/static/src/js/website.js"/> + <script type="text/javascript" src="/website/static/src/js/content/base.js"/> + <script type="text/javascript" src="/website/static/src/js/content/ready.js"/> + <script type="text/javascript" src="/website/static/src/js/content/context.js"/> <script type="text/javascript" src="/website/static/src/js/content/compatibility.js"/> <script type="text/javascript" src="/website/static/src/js/content/lazy_template_call.js"/> <script type="text/javascript" src="/website/static/src/js/content/menu.js"/> @@ -69,13 +73,15 @@ <template id="website.assets_editor" name="Website Editor Assets (used in website editor)"> <t t-call="web._assets_helpers"/> + <link rel="stylesheet" type="text/scss" href="/website/static/src/scss/website.editor.ui.scss"/> <link rel="stylesheet" type="text/scss" href="/website/static/src/scss/website.edit_mode.scss"/> - <script type="text/javascript" src="/website/static/lib/jQuery.transfo.js"/> - - <script type="text/javascript" src="/website/static/src/js/editor/editor.js"/> - <script type="text/javascript" src="/website/static/src/js/editor/rte.summernote.js"/> + <script type="text/javascript" src="/website/static/src/js/editor/editor_menu.js"/> + <script type="text/javascript" src="/website/static/src/js/editor/editor_menu_translate.js"/> + <script type="text/javascript" src="/website/static/src/js/editor/widget_link.js"/> <script type="text/javascript" src="/website/static/src/js/editor/snippets.options.js"/> + <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone.js"/> + <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone_translate.js"/> <script type="text/javascript" src="/website/static/src/js/menu/content.js"/> <script type="text/javascript" src="/website/static/src/js/menu/customize.js"/> @@ -91,6 +97,7 @@ <script type="text/javascript" src="/website/static/src/js/widgets/ace.js"/> <script type="text/javascript" src="/website/static/src/js/widgets/theme.js"/> + </template> <!-- Layout --> @@ -196,7 +203,6 @@ user_id: <t t-esc="json.dumps(request.env.user.id)" /> }; <t groups="website.group_website_publisher"> - odoo.snippetsURL = '/website/snippets'; odoo.session_info.website_id = <t t-esc="json.dumps(request.website.id)" />; odoo.session_info.website_company_id = <t t-esc="json.dumps(request.website.company_id.id)" />; </t> @@ -204,14 +210,10 @@ <t t-call-assets="web.assets_common" t-js="false"/> <t t-call-assets="web.assets_frontend" t-js="false"/> - <t t-call-assets="web_editor.summernote" t-js="false" groups="website.group_website_publisher"/> - <t t-call-assets="web_editor.assets_editor" t-js="false" groups="website.group_website_publisher"/> <t t-call-assets="website.assets_editor" t-js="false" groups="website.group_website_publisher"/> <t t-call-assets="web.assets_common" t-css="false"/> <t t-call-assets="web.assets_frontend" t-css="false"/> - <t t-call-assets="web_editor.summernote" t-css="false" groups="website.group_website_publisher"/> - <t t-call-assets="web_editor.assets_editor" t-css="false" groups="website.group_website_publisher"/> <t t-call-assets="website.assets_editor" t-css="false" groups="website.group_website_publisher"/> </t> </xpath> diff --git a/addons/website_blog/static/src/js/website.tour.blog.js b/addons/website_blog/static/src/js/website.tour.blog.js index d1afa46f217..a004f360e33 100644 --- a/addons/website_blog/static/src/js/website.tour.blog.js +++ b/addons/website_blog/static/src/js/website.tour.blog.js @@ -16,7 +16,7 @@ odoo.define("website_blog.tour", function (require) { position: "bottom", }, { trigger: "h1[data-oe-expression=\"blog_post.name\"]", - extra_trigger: "body.editor_has_snippets", + extra_trigger: ".o_snippets_loaded", content: _t("Write a title, the subtitle is optional."), position: "top", run: "text", @@ -26,7 +26,7 @@ odoo.define("website_blog.tour", function (require) { content: _t("Set a blog post <b>cover</b>."), position: "bottom", }, { - trigger: "a:containsExact(" + _t("Change Cover")+ "):eq(1)", + trigger: "a:containsExact(" + _t("Change Cover")+ "):visible", content: _t("Click here to change your post cover."), position: "right", }, { diff --git a/addons/website_blog/static/src/js/website_blog.editor.js b/addons/website_blog/static/src/js/website_blog.editor.js index 73944fc964e..0112faf636e 100644 --- a/addons/website_blog/static/src/js/website_blog.editor.js +++ b/addons/website_blog/static/src/js/website_blog.editor.js @@ -58,15 +58,15 @@ odoo.define('website_blog.editor', function (require) { 'use strict'; require('web.dom_ready'); -var weWidgets = require('web_editor.widget'); +var weWidgets = require('wysiwyg.widgets'); var options = require('web_editor.snippets.options'); -var rte = require('web_editor.rte'); +var WysiwygMultizone = require('web_editor.wysiwyg.multizone'); if (!$('.website_blog').length) { return $.Deferred().reject("DOM doesn't contain '.website_blog'"); } -rte.Class.include({ +WysiwygMultizone.include({ /** * @override */ @@ -82,14 +82,14 @@ rte.Class.include({ /** * @override */ - _saveElement: function ($el, context) { + _saveElement: function (outerHTML, recordInfo, editable) { var defs = [this._super.apply(this, arguments)]; // TODO the o_dirty class is not put on the right element for blog cover // edition. For some strange reason, it was forcly put (even if not // dirty) in <= saas-16 but this is not the case anymore. - var $blogContainer = $el.closest('.o_blog_cover_container'); + var $blogContainer = $(editable).closest('.o_blog_cover_container'); if (!this.__blogCoverSaved && $blogContainer.length) { - $el = $blogContainer; + var $el = $blogContainer; this.__blogCoverSaved = true; defs.push(this._rpc({ route: '/blog/post_change_background', @@ -97,9 +97,9 @@ rte.Class.include({ post_id: parseInt($el.closest('[name="blog_post"], .website_blog').find('[data-oe-model="blog.post"]').first().data('oe-id'), 10), cover_properties: { 'background-image': $el.children('.o_blog_cover_image').css('background-image').replace(/"/g, '').replace(window.location.protocol + "//" + window.location.host, ''), - 'background-color': $el.data('filterColor'), - 'opacity': $el.data('filterValue'), - 'resize_class': $el.data('coverClass'), + 'background-color': $el.attr('data-filterColor'), + 'opacity': $el.attr('data-filterValue'), + 'resize_class': $el.attr('data-coverClass'), }, }, })); @@ -244,9 +244,9 @@ options.registry.blog_cover = options.Class.extend({ return self.$filter.hasClass($(this).data('filterColor')); }).addClass('active').data('filterColor'); - this.$target.data('coverClass', this.$el.find('.active[data-select-class]').data('selectClass') || ''); - this.$target.data('filterValue', activeFilterValue || 0.0); - this.$target.data('filterColor', activeFilterColor || ''); + this.$target.attr('data-coverClass', this.$el.find('.active[data-select-class]').data('selectClass') || ''); + this.$target.attr('data-filterValue', activeFilterValue || 0.0); + this.$target.attr('data-filterColor', activeFilterColor || ''); }, }); }); diff --git a/addons/website_crm/views/website_crm_templates.xml b/addons/website_crm/views/website_crm_templates.xml index ae2201b4cfe..892ce2899f6 100644 --- a/addons/website_crm/views/website_crm_templates.xml +++ b/addons/website_crm/views/website_crm_templates.xml @@ -85,7 +85,7 @@ </field> </record> - <template id="website_crm_tests" name="Website CRM Tests" inherit_id="web.assets_common"> + <template id="website_crm_tests" name="Website CRM Tests" inherit_id="web.assets_frontend"> <xpath expr="." position="inside"> <script type="text/javascript" src="/website_crm/static/src/js/website_crm_tour.js"></script> </xpath> diff --git a/addons/website_forum/static/src/js/website_forum.editor.js b/addons/website_forum/static/src/js/website_forum.editor.js index b3b1049a0e3..40e96893151 100644 --- a/addons/website_forum/static/src/js/website_forum.editor.js +++ b/addons/website_forum/static/src/js/website_forum.editor.js @@ -3,7 +3,7 @@ odoo.define('website_forum.editor', function (require) { var core = require('web.core'); var Widget = require('web.Widget'); -var SummernoteManager = require('web_editor.rte.summernote'); +var Wysiwyg = require('web_editor.wysiwyg'); var WebsiteNewMenu = require('website.newMenu'); var wUtils = require('website.utils'); var websiteRootData = require('website.WebsiteRoot'); @@ -64,14 +64,4 @@ WebsiteNewMenu.include({ }, }); -var WebsiteForumManager = Widget.extend({ - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - new SummernoteManager(this); - }, -}); -websiteRootData.websiteRootRegistry.add(WebsiteForumManager, '#wrapwrap'); }); diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index e79dd72ccea..d8aa5f4c9b2 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -2,13 +2,21 @@ odoo.define('website_forum.website_forum', function (require) { 'use strict'; var core = require('web.core'); +var weContext = require('web_editor.context'); +var Wysiwyg = require('web_editor.wysiwyg'); +var rootWidget = require('root.widget'); var sAnimations = require('website.content.snippets.animation'); var session = require('web.session'); var qweb = core.qweb; var _t = core._t; -sAnimations.registry.websiteForum = sAnimations.Class.extend({ + +if (!$('.website_forum').length) { + return $.Deferred().reject("DOM doesn't contain '.website_forum'"); +} + +sAnimations.registryObject.add('websiteForum', sAnimations.Class.extend({ selector: '.website_forum', xmlDependencies: ['/website_forum/static/src/xml/website_forum_share_templates.xml'], read_events: { @@ -119,26 +127,41 @@ sAnimations.registry.websiteForum = sAnimations.Class.extend({ $textarea.val('<p><br/></p>'); } var $form = $textarea.closest('form'); + var hasFullEdit = parseInt($("#karma").val()) >= editorKarma; var toolbar = [ ['style', ['style']], ['font', ['bold', 'italic', 'underline', 'clear']], ['para', ['ul', 'ol', 'paragraph']], ['table', ['table']], - ['history', ['undo', 'redo']], ]; - if (parseInt($('#karma').val()) >= editorKarma) { - toolbar.push(['insert', ['link', 'picture']]); + if (hasFullEdit) { + toolbar.push(['insert', ['linkPlugin', 'mediaPlugin']]); } - $textarea.summernote({ + toolbar.push(['history', ['undo', 'redo']]); + + var options = { height: 150, toolbar: toolbar, - styleWithSpan: false - }); - - // float-left class messes up the post layout OPW 769721 - $form.find('.note-editable').find('img.float-left').removeClass('float-left'); - $form.on('click', 'button, .a-submit', function () { - $textarea.html($form.find('.note-editable').code()); + styleWithSpan: false, + recordInfo: { + context: weContext.get(), + res_model: 'forum.post', + res_id: +window.location.pathname.split('-').pop(), + }, + }; + if (!hasFullEdit) { + options.plugins = { + LinkPlugin: false, + MediaPlugin: false, + }; + } + var wysiwyg = new Wysiwyg(rootWidget, options); + wysiwyg.attachTo($textarea).then(function () { + // float-left class messes up the post layout OPW 769721 + $form.find('.note-editable').find('img.float-left').removeClass('float-left'); + $form.on('click', 'button, .a-submit', function () { + $form.find('textarea').data('wysiwyg').save(); + }); }); }); @@ -485,7 +508,7 @@ sAnimations.registry.websiteForum = sAnimations.Class.extend({ $('.forum_intro').slideUp(); return true; }, -}); +})); sAnimations.registry.websiteForumSpam = sAnimations.Class.extend({ selector: '.o_wforum_moderation_queue', diff --git a/addons/website_forum/views/website_forum.xml b/addons/website_forum/views/website_forum.xml index 1cd802fd7a0..da93b0d2526 100644 --- a/addons/website_forum/views/website_forum.xml +++ b/addons/website_forum/views/website_forum.xml @@ -34,12 +34,6 @@ <!-- website_forum.layout removes the access right check for summernote bundle --> <template id="layout" inherit_id="website.layout" name="Forum Layout" primary="True"> - <xpath expr="//t[@t-call-assets='web_editor.summernote'][@t-js='false']" position="attributes"> - <attribute name="groups"/> - </xpath> - <xpath expr="//t[@t-call-assets='web_editor.summernote'][@t-css='false']" position="attributes"> - <attribute name="groups"/> - </xpath> <xpath expr="//div[@id='wrapwrap']" position="before"> <t t-set="pageName" t-value="'website_forum'"/> </xpath> diff --git a/addons/website_gengo/static/src/js/website_gengo.js b/addons/website_gengo/static/src/js/website_gengo.js index 72c472b5b28..685a41ea945 100644 --- a/addons/website_gengo/static/src/js/website_gengo.js +++ b/addons/website_gengo/static/src/js/website_gengo.js @@ -6,8 +6,7 @@ var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); var weContext = require('web_editor.context'); -require('web_editor.editor'); -var translate = require('web_editor.translate'); +var WysiwygTranslate = require('web_editor.wysiwyg.multizone.translate'); var qweb = core.qweb; var _t = core._t; @@ -19,8 +18,8 @@ if (!weContext.getExtra().edit_translations) { ajax.loadXML('/website_gengo/static/src/xml/website.gengo.xml', qweb); -translate.Class.include({ - events: _.extend({}, translate.Class.prototype.events, { +WysiwygTranslate.include({ + events: _.extend({}, WysiwygTranslate.prototype.events, { 'click a[data-action=translation_gengo_post]': 'translation_gengo_post', 'click a[data-action=translation_gengo_info]': 'translation_gengo_info', }), diff --git a/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml b/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml index 958393fa4a6..fd26eb62071 100644 --- a/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml +++ b/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml @@ -518,7 +518,7 @@ </xpath> </template> -<template id="website_hr_recuitment_tests" name="Website HR Recruitment Tests" inherit_id="web.assets_common"> +<template id="website_hr_recuitment_tests" name="Website HR Recruitment Tests" inherit_id="web.assets_frontend"> <xpath expr="." position="inside"> <script type="text/javascript" src="/website_hr_recruitment/static/src/js/website_hr_recruitment_tour.js"></script> </xpath> diff --git a/addons/website_mass_mailing/__manifest__.py b/addons/website_mass_mailing/__manifest__.py index 3691d7a5da7..7697f3373f6 100644 --- a/addons/website_mass_mailing/__manifest__.py +++ b/addons/website_mass_mailing/__manifest__.py @@ -18,5 +18,8 @@ On a simple click, your visitors can subscribe to mailing lists managed in the E 'views/mass_mailing_view.xml', 'views/res_config_settings_views.xml', ], + 'qweb': [ + 'static/src/xml/*.xml', + ], 'auto_install': True, } diff --git a/addons/website_mass_mailing/controllers/main.py b/addons/website_mass_mailing/controllers/main.py index bb1e66b1efe..3676ae26741 100644 --- a/addons/website_mass_mailing/controllers/main.py +++ b/addons/website_mass_mailing/controllers/main.py @@ -24,18 +24,19 @@ class MassMailController(MassMailController): @route('/website_mass_mailing/subscribe', type='json', website=True, auth="public") def subscribe(self, list_id, email, **post): + List_contact_rel = request.env['mail.mass_mailing.list_contact_rel'].sudo() Contacts = request.env['mail.mass_mailing.contact'].sudo() name, email = Contacts.get_name_email(email) - contact_ids = Contacts.search([ - ('list_ids', 'in', [int(list_id)]), - ('email', '=', email), - ], limit=1) - if not contact_ids: + list_contact_rel = List_contact_rel.search([('list_id', '=', int(list_id)), ('contact_id.email', '=', email)], limit=1) + if not list_contact_rel: # inline add_to_list as we've already called half of it - Contacts.create({'name': name, 'email': email, 'list_ids': [(6,0,[int(list_id)])]}) - elif contact_ids.opt_out: - contact_ids.opt_out = False + contact_id = Contacts.search([('email', '=', email)], limit=1) + if not contact_id: + contact_id = Contacts.create({'name': name, 'email': email}) + List_contact_rel.create({'contact_id': contact_id.id, 'list_id': int(list_id)}) + elif list_contact_rel.opt_out: + list_contact_rel.opt_out = False # add email to session request.session['mass_mailing_email'] = email return True diff --git a/addons/mass_mailing/static/src/css/mass_mailing_popup.css b/addons/website_mass_mailing/static/src/css/website_mass_mailing_popup.css index 1ebd8b85beb..1ebd8b85beb 100644 --- a/addons/mass_mailing/static/src/css/mass_mailing_popup.css +++ b/addons/website_mass_mailing/static/src/css/website_mass_mailing_popup.css diff --git a/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js b/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js index bb85683b2a7..e980cd0b048 100644 --- a/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js +++ b/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js @@ -4,12 +4,12 @@ odoo.define('website_mass_mailing.editor', function (require) { var ajax = require('web.ajax'); var core = require('web.core'); var rpc = require('web.rpc'); -var weContext = require('web_editor.context'); -var web_editor = require('web_editor.editor'); +var WysiwygMultizone = require('web_editor.wysiwyg.multizone'); var options = require('web_editor.snippets.options'); var wUtils = require('website.utils'); var _t = core._t; + var mass_mailing_common = options.Class.extend({ popup_template_id: "editor_new_mailing_list_subscribe_button", popup_title: _t("Add a Newsletter Subscribe Button"), @@ -24,7 +24,7 @@ var mass_mailing_common = options.Class.extend({ model: 'mail.mass_mailing.list', method: 'name_search', args: ['', []], - context: weContext.get(), // TODO use this._rpc + context: self.options.recordInfo.context, }); }, }); @@ -54,10 +54,10 @@ options.registry.newsletter_popup = mass_mailing_common.extend({ select_mailing_list: function (previewMode, value) { var self = this; return this._super(previewMode, value).then(function (mailing_list_id) { - self._rpc({ + ajax.jsonRpc('/web/dataset/call', 'call', { model: 'mail.mass_mailing.list', method: 'read', - args: [[parseInt(mailing_list_id)], ['popup_content']], + args: [[parseInt(mailing_list_id)], ['popup_content'], self.options.recordInfo.context], }).then(function (data) { self.$target.find(".o_popup_content_dev").empty(); if (data && data[0].popup_content) { @@ -68,34 +68,46 @@ options.registry.newsletter_popup = mass_mailing_common.extend({ }, }); -web_editor.Class.include({ - start: function () { - $('body').on('click','#edit_dialog',_.bind(this.edit_dialog, this.rte.editor)); - return this._super(); - }, +WysiwygMultizone.include({ + events: _.extend({}, WysiwygMultizone.prototype.events, { + 'click #edit_dialog': 'edit_dialog', + 'click .o_popup_modal_content [data-dismiss="modal"]': 'close_dialog', + }), save: function () { var $target = $('#wrapwrap').find('#o_newsletter_popup'); if ($target && $target.length) { - $target.modal('hide'); - $target.css("display", "none"); + this.close_dialog(); $('.o_popup_bounce_small').show(); if (!$target.find('.o_popup_content_dev').length) { $target.find('.o_popup_modal_body').prepend($('<div class="o_popup_content_dev" data-oe-placeholder="' + _t("Type Here ...") + '"></div>')); } var content = $('#wrapwrap .o_popup_content_dev').html(); var newsletter_id = $target.parent().attr('data-list-id'); - this._rpc({ + ajax.jsonRpc('/web/dataset/call', 'call', { model: 'mail.mass_mailing.list', method: 'write', args: [ parseInt(newsletter_id), {'popup_content':content}, + this.options.recordInfo.context, ], }); } return this._super.apply(this, arguments); }, - edit_dialog: function () { + destroy: function () { + this.close_dialog(); + this._super(); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + close_dialog: function () { + $('#wrapwrap').find('#o_newsletter_popup').modal('hide'); + }, + edit_dialog: function (ev) { $('#wrapwrap').find('#o_newsletter_popup').modal('show'); $('.o_popup_bounce_small').hide(); $('.modal-backdrop').css("z-index", "0"); diff --git a/addons/website_mass_mailing/static/src/xml/website_mass_mailing.xml b/addons/website_mass_mailing/static/src/xml/website_mass_mailing.xml new file mode 100644 index 00000000000..56ee3661378 --- /dev/null +++ b/addons/website_mass_mailing/static/src/xml/website_mass_mailing.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> +<t t-name="website_mass_mailing.edition.wrapper"> + <div role="dialog" class="modal-dialog modal-md d-block" id="o_newsletter_popup"> + <div class="modal-content o_popup_modal_content"> + <div class="o_popup_modal_body text-center"> + <div class="o_popup_content_dev"> + <div id="wrapper"></div> + </div> + </div> + </div> + </div> +</t> +</templates> diff --git a/addons/website_mass_mailing/views/mass_mailing_view.xml b/addons/website_mass_mailing/views/mass_mailing_view.xml index 526af440978..74fee0cd99d 100644 --- a/addons/website_mass_mailing/views/mass_mailing_view.xml +++ b/addons/website_mass_mailing/views/mass_mailing_view.xml @@ -8,7 +8,11 @@ <xpath expr="//sheet" position="inside"> <notebook groups="website_mass_mailing.group_website_popup_on_exit"> <page string="Popup Content"> - <field name="popup_content" widget="mass_mailing_html_frame" options="{'editor_url': '/website_mass_mailing/field/popup_content'}"/> + <field name="popup_content" widget="html" options="{ + 'snippets': 'website.snippets', + 'cssEdit': 'website_mass_mailing.iframe_css_assets_edit', + 'wrapper': 'website_mass_mailing.edition.wrapper', + }"/> </page> </notebook> </xpath> diff --git a/addons/website_mass_mailing/views/snippets_templates.xml b/addons/website_mass_mailing/views/snippets_templates.xml index ff84153148e..f0fee84ad53 100644 --- a/addons/website_mass_mailing/views/snippets_templates.xml +++ b/addons/website_mass_mailing/views/snippets_templates.xml @@ -1,5 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> +<template id="iframe_css_assets_edit" name="CSS assets for wysiwyg iframe content for popup"> + <t t-call-assets="web.assets_common" t-js="false"/> + <t t-call-assets="web.assets_frontend" t-js="false"/> + <link rel="stylesheet" href="/website_mass_mailing/static/src/css/website_mass_mailing_popup.css" type="text/css"/> +</template> + <template id="remove_external_snippets" inherit_id="website.external_snippets"> <xpath expr="//t[@t-install='mass_mailing']" position="replace"/> </template> @@ -32,7 +38,7 @@ <div class="alert alert-warning alert-dismissible fade show css_non_editable_mode_hidden o_not_editable" role="status"> <strong>Newsletter Popup!</strong> The newsletter popup snippet effect is active on this page. Click <a id="edit_dialog" href="#">Here To Edit Dialog Content</a> </div> - <div role="dialog" class="modal modal-dialog modal-md fade fade-custom" id="o_newsletter_popup" tabindex="-1"> + <div role="dialog" class="modal modal-dialog modal-md fade-custom" id="o_newsletter_popup" tabindex="-1"> <div class="modal-content o_popup_modal_content"> <a role="button" href="#" class="o_popup_btn_close o_not_editable" data-dismiss="modal">&times;</a> @@ -69,6 +75,9 @@ <!-- Extend default mass_mailing snippets with website feature --> <template id="social_links"> + <t t-if="not website"> + <t t-set="website" t-value="request.env['website'].search([], limit=1)"/> + </t> <t t-if="website.social_facebook"> <a t-att-href="website.social_facebook" aria-label="Facebook" title="Facebook"> <span class="fa fa-facebook"></span> diff --git a/addons/website_mass_mailing/views/website_mass_mailing_templates.xml b/addons/website_mass_mailing/views/website_mass_mailing_templates.xml index 52dae5c3068..a30afe0b846 100644 --- a/addons/website_mass_mailing/views/website_mass_mailing_templates.xml +++ b/addons/website_mass_mailing/views/website_mass_mailing_templates.xml @@ -3,7 +3,7 @@ <template id="assets_frontend" inherit_id="website.assets_frontend"> <xpath expr="//link[last()]" position="after"> - <link rel="stylesheet" href="/mass_mailing/static/src/css/mass_mailing_popup.css" type="text/css"/> + <link rel="stylesheet" href="/website_mass_mailing/static/src/css/website_mass_mailing_popup.css" type="text/css"/> </xpath> <xpath expr="//script[last()]" position="after"> <script type="text/javascript" src="/website_mass_mailing/static/src/js/website_mass_mailing.js"/> diff --git a/addons/website_sale/static/src/js/website_sale_options.js b/addons/website_sale/static/src/js/website_sale_options.js index ab3542589af..9390eccf31e 100644 --- a/addons/website_sale/static/src/js/website_sale_options.js +++ b/addons/website_sale/static/src/js/website_sale_options.js @@ -85,7 +85,8 @@ sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend(ProductConfig isWebsite: true, okButtonText: _t('Proceed to Checkout'), cancelButtonText: _t('Continue Shopping'), - title: _t('Add to cart') + title: _t('Add to cart'), + context: weContext.get(), }).open(); self.optionalProductsModal.on('options_empty', null, self._onModalOptionsEmpty.bind(self)); diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop.js b/addons/website_sale/static/src/js/website_sale_tour_shop.js index 6847bdd3bd2..deafdd83369 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_shop.js +++ b/addons/website_sale/static/src/js/website_sale_tour_shop.js @@ -2,15 +2,11 @@ odoo.define("website_sale.tour_shop", function (require) { "use strict"; var core = require("web.core"); - var tour = require("web_tour.tour"); - var base = require("web_editor.base"); - var _t = core._t; - tour.register("shop", { - url: "/shop", - wait_for: base.ready(), - }, [{ + // return the steps, used for backend and frontend (because web_editor.base is only in the website) + + return [{ trigger: "#new-content-menu > a", content: _t("Let's create your first product."), extra_trigger: ".js_sale", @@ -28,13 +24,14 @@ odoo.define("website_sale.tour_shop", function (require) { content: _t("Click on <em>Continue</em> to create the product."), position: "right", }, { - trigger: ".product_price .o_is_inline_editable .oe_currency_value", + trigger: ".product_price .oe_currency_value:visible", + extra_trigger: ".note-editable", content: _t("Edit the price of this product by clicking on the amount."), position: "bottom", run: "text 1.99", }, { trigger: "#wrap img.product_detail_img", - extra_trigger: ".product_price .o_is_inline_editable .oe_currency_value:not(:containsExact(1.00))", + extra_trigger: ".product_price .o_dirty .oe_currency_value:not(:containsExact(1.00))", content: _t("Double click here to set an image describing your product."), position: "top", run: function (actions) { @@ -66,10 +63,10 @@ odoo.define("website_sale.tour_shop", function (require) { trigger: ".o_main_navbar .o_menu_toggle, #oe_applications .dropdown-toggle", content: _t("Let's now take a look at your administration dashboard to get your eCommerce website ready in no time."), position: "bottom", - }, { + }, { // backend trigger: '.o_apps > a[data-menu-xmlid="website.menu_website_configuration"], #oe_main_menu_navbar a[data-menu-xmlid="website.menu_website_configuration"]', content: _t("Open your website app here."), extra_trigger: ".o_apps,#oe_applications", position: "bottom", - }]); + }]; }); diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_backend.js b/addons/website_sale/static/src/js/website_sale_tour_shop_backend.js new file mode 100644 index 00000000000..ad31f8d4ab3 --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_backend.js @@ -0,0 +1,8 @@ +odoo.define("website_sale.tour_shop_backend", function (require) { +"use strict"; + +var tour = require("web_tour.tour"); +var steps = require("website_sale.tour_shop"); +tour.register("shop", steps); + +}); diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_frontend.js b/addons/website_sale/static/src/js/website_sale_tour_shop_frontend.js new file mode 100644 index 00000000000..f2aa39af42b --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_frontend.js @@ -0,0 +1,12 @@ +odoo.define("website_sale.tour_shop_frontend", function (require) { +"use strict"; + +var tour = require("web_tour.tour"); +var base = require("web_editor.base"); +var steps = require("website_sale.tour_shop"); +tour.register("shop", { + url: "/shop", + wait_for: base.ready(), +}, steps); + +}); diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 361197644d9..4fe65012668 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -6,6 +6,7 @@ <script type="text/javascript" src="/website_sale/static/src/js/website_sale_backend.js"></script> <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_dashboard.scss"/> <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_backend.scss"/> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_backend.js"></script> </xpath> </template> @@ -40,6 +41,7 @@ <template id="assets_editor" inherit_id="website.assets_editor" name="Shop Editor"> <xpath expr="." position="inside"> <script type="text/javascript" src="/website_sale/static/src/js/website_sale.editor.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_frontend.js"></script> </xpath> </template> diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index a8f67c6f86c..4b0cae5d189 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -340,14 +340,16 @@ class AssetsBundle(object): if (window.__assetsBundleErrorSeen) return; window.__assetsBundleErrorSeen = true; - document.addEventListener("DOMContentLoaded", function () { + var loaded = function () { + clearTimeout(loadedTimeout); var alertTimeout = setTimeout(alert.bind(window, message), 0); - if (typeof odoo === "undefined") return; + var odoo = window.top.odoo; + if (!odoo || !odoo.define) return; odoo.define("AssetsBundle.ErrorMessage", function (require) { "use strict"; - var base = require("web_editor.base"); + require('web.dom_ready'); var core = require("web.core"); var Dialog = require("web.Dialog"); @@ -355,16 +357,17 @@ class AssetsBundle(object): clearTimeout(alertTimeout); - base.ready().then(function () { - new Dialog(null, { - title: _t("Style error"), - $content: $("<div/>") - .append($("<p/>", {text: _t("The style compilation failed, see the error below. Your recent actions may be the cause, please try reverting the changes you made.")})) - .append($("<pre/>", {html: message})), - }).open(); - }); + new Dialog(null, { + title: _t("Style error"), + $content: $("<div/>") + .append($("<p/>", {text: _t("The style compilation failed, see the error below. Your recent actions may be the cause, please try reverting the changes you made.")})) + .append($("<pre/>", {html: message})), + }).open(); }); - }); + } + + var loadedTimeout = setTimeout(loaded, 5000); + document.addEventListener("DOMContentLoaded", loaded); })("%s"); """ % message.replace('"', '\\"').replace('\n', '
') diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index 0463170ea90..7212ceb401d 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -478,7 +478,7 @@ class IrActionsReport(models.Model): if values is None: values = {} - context = dict(self.env.context, inherit_branding=values.get('enable_editor')) + context = dict(self.env.context, inherit_branding=False) # Browse the user instead of using the sudo self.env.user user = self.env['res.users'].browse(self.env.uid) @@ -492,7 +492,6 @@ class IrActionsReport(models.Model): values.update( time=time, context_timestamp=lambda t: fields.Datetime.context_timestamp(self.with_context(tz=user.tz), t), - editable=values.get('enable_editor'), user=user, res_company=user.company_id, website=website, @@ -610,9 +609,6 @@ class IrActionsReport(models.Model): if not data: data = {} - # remove editor feature in pdf generation - data.update(enable_editor=False) - # In case of test environment without enough workers to perform calls to wkhtmltopdf, # fallback to render_html. if (tools.config['test_enable'] or tools.config['test_file']) and not self.env.context.get('force_report_rendering'): diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py index 1220b51a7c6..dcc03f0b36e 100644 --- a/odoo/addons/base/models/ir_qweb_fields.py +++ b/odoo/addons/base/models/ir_qweb_fields.py @@ -658,7 +658,7 @@ class Contact(models.AbstractModel): 'object': value, 'options': options } - return self.env['ir.qweb'].render('base.contact', val, **options.get('template_options')) + return self.env['ir.qweb'].render('base.contact', val, **options.get('template_options', dict())) class QwebView(models.AbstractModel): |
