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 /addons/web_editor/static/src/js/backend | |
| 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
Diffstat (limited to 'addons/web_editor/static/src/js/backend')
| -rw-r--r-- | addons/web_editor/static/src/js/backend/convert_inline.js | 472 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/backend/field_html.js | 476 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/backend/fields.js | 532 |
3 files changed, 948 insertions, 532 deletions
diff --git a/addons/web_editor/static/src/js/backend/convert_inline.js b/addons/web_editor/static/src/js/backend/convert_inline.js new file mode 100644 index 00000000000..3b09bef5f31 --- /dev/null +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -0,0 +1,472 @@ +odoo.define('web_editor.convertInline', function (require) { +'use strict'; + +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 + * browser/mail client ok. + * + * @param {DOMElement} a + * @returns {Object} css property name -> css property value + */ +function getMatchedCSSRules(a) { + var i, r, k; + var doc = a.ownerDocument; + var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []); + + if (!rulesCache.length) { + 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 + try { + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (rules) { + for (r = rules.length-1; r >= 0; r--) { + var selectorText = rules[r].selectorText; + if (selectorText && + rules[r].cssText && + selectorText !== '*' && + selectorText.indexOf(':hover') === -1 && + selectorText.indexOf(':before') === -1 && + selectorText.indexOf(':after') === -1 && + selectorText.indexOf(':active') === -1 && + selectorText.indexOf(':link') === -1 && + selectorText.indexOf('::') === -1 && + selectorText.indexOf("'") === -1) { + var st = selectorText.split(/\s*,\s*/); + for (k = 0 ; k < st.length ; k++) { + rulesCache.push({ 'selector': st[k], 'style': rules[r].style }); + } + } + } + } + } + rulesCache.reverse(); + } + + var css = []; + var style; + a.matches = a.matches || a.webkitMatchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector; + for (r = 0; r < rulesCache.length; r++) { + if (a.matches(rulesCache[r].selector)) { + style = rulesCache[r].style; + if (style.parentRule) { + var style_obj = {}; + var len; + for (k = 0, len = style.length ; k < len ; k++) { + if (style[k].indexOf('animation') !== -1) { + continue; + } + style_obj[style[k]] = style[style[k].replace(/-(.)/g, function (a, b) { return b.toUpperCase(); })]; + if (new RegExp(style[k] + '\s*:[^:;]+!important' ).test(style.cssText)) { + style_obj[style[k]] += ' !important'; + } + } + rulesCache[r].style = style = style_obj; + } + css.push([rulesCache[r].selector, style]); + } + } + + function specificity(selector) { + // http://www.w3.org/TR/css3-selectors/#specificity + var a = 0; + selector = selector.replace(/#[a-z0-9_-]+/gi, function () { a++; return ''; }); + var b = 0; + selector = selector.replace(/(\.[a-z0-9_-]+)|(\[.*?\])/gi, function () { b++; return ''; }); + var c = 0; + selector = selector.replace(/(^|\s+|:+)[a-z0-9_-]+/gi, function (a) { if (a.indexOf(':not(')===-1) c++; return ''; }); + return a*100 + b*10 + c; + } + css.sort(function (a, b) { return specificity(a[0]) - specificity(b[0]); }); + + style = {}; + _.each(css, function (v,k) { + _.each(v[1], function (v,k) { + if (v && _.isString(v) && k.indexOf('-webkit') === -1 && (!style[k] || style[k].indexOf('important') === -1 || v.indexOf('important') !== -1)) { + style[k] = v; + } + }); + }); + + _.each(style, function (v,k) { + if (v.indexOf('important') !== -1) { + style[k] = v.slice(0, v.length-11); + } + }); + + if (style.display === 'block') { + delete style.display; + } + + // The css generates all the attributes separately and not in simplified form. + // In order to have a better compatibility (outlook for example) we simplify the css tags. + // e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none + _.each([ + {property: 'margin'}, + {property: 'padding'}, + {property: 'border', propertyEnd: '-style', defaultValue: 'none'}, + ], function (propertyInfo) { + var p = propertyInfo.property; + var e = propertyInfo.propertyEnd || ''; + var defVal = propertyInfo.defaultValue || 0; + + if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) { + if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) { + // keep => property: [top/right/bottom/left value]; + style[p+e] = style[p+'-top'+e]; + } + else { + // keep => property: [top value] [right value] [bottom value] [left value]; + style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal); + if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) { + // keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value]; + delete style[p+e]; + return; + } + } + delete style[p+'-top'+e]; + delete style[p+'-right'+e]; + delete style[p+'-bottom'+e]; + delete style[p+'-left'+e]; + } + }); + + if (style['border-bottom-left-radius']) { + style['border-radius'] = style['border-bottom-left-radius']; + delete style['border-bottom-left-radius']; + delete style['border-bottom-right-radius']; + delete style['border-top-left-radius']; + delete style['border-top-right-radius']; + } + + // if the border styling is initial we remove it to simplify the css tags for compatibility. + // Also, since we do not send a css style tag, the initial value of the border is useless. + _.each(_.keys(style), function (k) { + if (k.indexOf('border') !== -1 && style[k] === 'initial') { + delete style[k]; + } + }); + + // text-decoration rule is decomposed in -line, -color and -style. This is + // however not supported by many browser/mail clients and the editor does + // not allow to change -color and -style rule anyway + if (style['text-decoration-line']) { + style['text-decoration'] = style['text-decoration-line']; + delete style['text-decoration-line']; + delete style['text-decoration-color']; + delete style['text-decoration-style']; + } + + // text-align inheritance does not seem to get past <td> elements on some + // mail clients + if (style['text-align'] === 'inherit') { + var $el = $(a).parent(); + do { + var align = $el.css('text-align'); + if (_.indexOf(['left', 'right', 'center', 'justify'], align) >= 0) { + style['text-align'] = align; + break; + } + $el = $el.parent(); + } while (!$el.is('html')); + } + + return style; +} + +/** + * Converts font icons to images. + * + * @param {jQuery} $editable - the element in which the font icons have to be + * converted to images + */ +function fontToImg($editable) { + $editable.find('.fa').each(function () { + var $font = $(this); + var icon, content; + _.find(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]; + return true; + } + }); + }); + if (content) { + var color = $font.css('color').replace(/\s/g, ''); + $font.replaceWith($('<img/>', { + src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, $font.height())), + 'data-class': $font.attr('class'), + 'data-style': $font.attr('style'), + class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style + style: $font.attr('style'), + }).css({height: 'auto', width: 'auto'})); + } else { + $font.remove(); + } + }); +} + +/** + * Converts images which were the result of a font icon convertion to a font + * icon again. + * + * @param {jQuery} $editable - the element in which the images will be converted + * back to font icons + */ +function imgToFont($editable) { + $editable.find('img[src*="/web_editor/font_to_img/"]').each(function () { + var $img = $(this); + $img.replaceWith($('<span/>', { + class: $img.data('class'), + style: $img.data('style') + })); + }); +} + +/* + * Utility function to apply function over descendants elements + * + * This is needed until the following issue of jQuery is solved: + * https://github.com./jquery/sizzle/issues/403 + * + * @param {Element} node The root Element node + * @param {Function} func The function applied over descendants + */ +function applyOverDescendants(node, func) { + node = node.firstChild; + while (node) { + if (node.nodeType === 1) { + func(node); + applyOverDescendants(node, func); + } + var $node = $(node); + if (node.nodeName === 'A' && $node.hasClass('btn') && !$node.children().length && $(node).parents('.o_outlook_hack').length) { + node = $(node).parents('.o_outlook_hack')[0]; + } + else if (node.nodeName === 'IMG' && $node.parent('p').hasClass('o_outlook_hack')) { + node = $node.parent()[0]; + } + node = node.nextSibling; + } +} + +/** + * Converts css style to inline style (leave the classes on elements but forces + * the style they give as inline style). + * + * @param {jQuery} $editable + */ +function classToStyle($editable) { + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = $target.attr('style') || ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + if (_.isEmpty(style)) { + $target.removeAttr('style'); + } else { + $target.attr('style', style); + } + // Apple Mail + if (node.nodeName === 'TD' && !node.childNodes.length) { + node.innerHTML = ' '; + } + + // Outlook + if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) { + var $hack = $('<table class="o_outlook_hack" style="display: inline-table;vertical-align:middle"><tr><td></td></tr></table>'); + $hack.find('td') + .attr('height', $target.outerHeight()) + .css({ + 'text-align': $target.parent().css('text-align'), + 'margin': $target.css('padding'), + 'border-radius': $target.css('border-radius'), + 'background-color': $target.css('background-color'), + }); + $target.after($hack); + $target.appendTo($hack.find('td')); + // the space add a line when it's a table but it's invisible when it's a link + node = $hack[0].previousSibling; + if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { + $(node).remove(); + } + node = $hack[0].nextSibling; + if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { + $(node).remove(); + } + } + else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) { + $target.wrap('<p class="o_outlook_hack" style="text-align:center;margin:0"/>'); + } + }); +} + +/** + * Removes the inline style which is not necessary (because, for example, a + * class on an element will induce the same style). + * + * @param {jQuery} $editable + */ +function styleToClass($editable) { + // Outlook revert + $editable.find('.o_outlook_hack').each(function () { + $(this).after($('a,img', this)); + }).remove(); + + var $c = $('<span/>').appendTo($editable[0].ownerDocument.body); + + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); + style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';'); + _.each(css, function (v) { + style = style.replace(v, ''); + }); + style = style.replace(/;+(\s;)*/g, ';').replace(/^;/g, ''); + if (style !== '') { + $target.attr('style', style); + } else { + $target.removeAttr('style'); + } + }); + $c.remove(); +} + +/** + * Converts css display for attachment link to real image. + * Without this post process, the display depends on the css and the picture + * does not appear when we use the html without css (to send by email for e.g.) + * + * @param {jQuery} $editable + */ +function attachmentThumbnailToLinkImg($editable) { + $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.prepend($img); + }); +} + +/** + * Revert attachmentThumbnailToLinkImg changes + * + * @see attachmentThumbnailToLinkImg + * @param {jQuery} $editable + */ +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, + classToStyle: classToStyle, + styleToClass: styleToClass, + attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg, + linkImgToAttachmentThumbnail: linkImgToAttachmentThumbnail, +}; +}); 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, -}; -}); |
