diff options
Diffstat (limited to 'addons/web_editor/static/src')
65 files changed, 14442 insertions, 9478 deletions
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/content/body_manager.js b/addons/web_editor/static/src/js/content/body_manager.js deleted file mode 100644 index 1045e94a65a..00000000000 --- a/addons/web_editor/static/src/js/content/body_manager.js +++ /dev/null @@ -1,60 +0,0 @@ -odoo.define('web_editor.BodyManager', function (require) { -'use strict'; - -var weContext = require('web_editor.context'); -var rootWidget = require('web_editor.root_widget'); -var ServiceProviderMixin = require('web.ServiceProviderMixin'); -var session = require('web.session'); - -/** - * Element which is designed to be unique and that will be the top-most element - * in the widget hierarchy. So, all other widgets will be indirectly linked to - * this Class instance. Its main role will be to retrieve RPC demands from its - * children and handle them. - */ -var BodyManager = rootWidget.RootWidget.extend(ServiceProviderMixin, { - /** - * @constructor - */ - init: function () { - this._super.apply(this, arguments); - ServiceProviderMixin.init.call(this); - }, - /** - * @override - */ - willStart: function () { - return $.when( - this._super.apply(this, arguments), - session.is_bound - ); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Automatically add the web_editor context. - * - * @override - */ - _call_service: function (event) { - if (event.data.service === 'ajax' && event.data.method === 'rpc') { - var route = event.data.args[0]; - if (_.str.startsWith(route, '/web/dataset/call_kw/')) { - var params = event.data.args[1]; - var options = event.data.args[2]; - params.kwargs.context = _.extend({}, weContext.get(), params.kwargs.context || {}); - if (options) { - params.kwargs.context = _.omit(params.kwargs.context, options.noContextKeys); - event.data.args[2] = _.omit(options, 'noContextKeys'); - } - params.kwargs.context = JSON.parse(JSON.stringify(params.kwargs.context)); - } - } - return ServiceProviderMixin._call_service.apply(this, arguments); - }, -}); -return BodyManager; -}); 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_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js new file mode 100644 index 00000000000..3538b1ad874 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js @@ -0,0 +1,563 @@ +odoo.define('wysiwyg.widgets.ColorpickerDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var utils = require('web.utils'); +var Dialog = require('web.Dialog'); + +var _t = core._t; + +var ColorpickerDialog = Dialog.extend({ + xmlDependencies: (Dialog.prototype.xmlDependencies || []) + .concat(['/web_editor/static/src/xml/wysiwyg_colorpicker.xml']), + + template: 'wysiwyg.widgets.ColorpickerDialog', + events: _.extend({}, Dialog.prototype.events || {}, { + 'mousedown .o_color_pick_area': '_onMouseDownPicker', + 'mousedown .o_color_slider': '_onMouseDownSlider', + 'mousedown .o_opacity_slider': '_onMouseDownOpacitySlider', + 'change .o_color_picker_inputs' : '_onChangeInputs', + }), + + /** + * @constructor + * @param {Widget} parent + * @param {Object} [options] + * @param {string} [options.defaultColor='#FF0000'] + */ + init: function (parent, options) { + options = options || {}; + + this._super(parent, _.extend({ + size: 'medium', + title: _t('Pick a color'), + buttons: [ + {text: _t('Choose'), classes: 'btn-primary', close: true, click: this._onFinalPick.bind(this)}, + {text: _t('Discard'), close: true}, + ], + }, options)); + + this.trigger_up('getRecordInfo', { + recordInfo: options, + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + this.colorComponents = {}; + + var self = this; + var $body = $(document.body); + $body.on('mousemove.colorpicker', _.throttle(function (ev) { + self._onMouseMovePicker(ev); + self._onMouseMoveSlider(ev); + self._onMouseMoveOpacitySlider(ev); + }, 10)); + $body.on('mouseup.colorpicker', _.throttle(function (ev) { + self.pickerFlag = false; + self.sliderFlag = false; + self.opacitySliderFlag = false; + }, 10)); + + this.options = _.clone(options); + }, + /** + * @override + */ + start: function () { + this.$colorpickerArea = this.$('.o_color_pick_area'); + this.$colorpickerPointer = this.$('.o_picker_pointer'); + this.$colorSlider = this.$('.o_color_slider'); + this.$colorSliderPointer = this.$('.o_slider_pointer'); + this.$opacitySlider = this.$('.o_opacity_slider'); + this.$opacitySliderPointer = this.$('.o_opacity_pointer'); + + var defaultColor = this.options.defaultColor || '#FF0000'; + var rgba = ColorpickerDialog.convertColorToRgba(defaultColor); + if (rgba) { + this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); + } + this.opened().then(this._updateUI.bind(this)); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + $(document.body).off('.colorpicker'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Updates input values, color preview, picker and slider pointer positions. + * + * @private + */ + _updateUI: function () { + var self = this; + + // Update inputs + _.each(this.colorComponents, function (value, color) { + self.$(_.str.sprintf('.o_%s_input', color)).val(value); + }); + + // Update preview + this.$('.o_color_preview').css('background-color', this.colorComponents.cssColor); + + // Update picker area and picker pointer position + this.$colorpickerArea.css('background-color', _.str.sprintf('hsl(%s, 100%%, 50%%)', this.colorComponents.hue)); + var top = (100 - this.colorComponents.lightness) * this.$colorpickerArea.height() / 100; + var left = this.colorComponents.saturation * this.$colorpickerArea.width() / 100; + this.$colorpickerPointer.css({ + top: (top - 5) + 'px', + left: (left - 5) + 'px', + }); + + // Update color slider position + var height = this.$colorSlider.height(); + var y = this.colorComponents.hue * height / 360; + this.$colorSliderPointer.css('top', Math.round(y - 2)); + + // Update opacity slider position + var heightOpacity = this.$opacitySlider.height(); + var z = heightOpacity * (1 - this.colorComponents.opacity / 100.0); + this.$opacitySliderPointer.css('top', Math.round(z - 2)); + + // Add gradient color on opacity slider + this.$opacitySlider.css('background', 'linear-gradient(' + this.colorComponents.hex + ' 0%, transparent 100%)'); + }, + /** + * Updates colors according to given hex value. Opacity is left unchanged. + * + * @private + * @param {string} hex - hexadecimal code + */ + _updateHex: function (hex) { + var rgb = ColorpickerDialog.convertHexToRgba(hex); + if (!rgb) { + return; + } + _.extend(this.colorComponents, + {hex: hex}, + rgb, + ColorpickerDialog.convertRgbToHsl(rgb.red, rgb.green, rgb.blue) + ); + this._updateCssColor(); + }, + /** + * Updates colors according to given RGB values. + * + * @private + * @param {integer} r + * @param {integer} g + * @param {integer} b + * @param {integer} [a] + */ + _updateRgba: function (r, g, b, a) { + var hex = ColorpickerDialog.convertRgbToHex(r, g, b); + if (!hex) { + return; + } + _.extend(this.colorComponents, + {red: r, green: g, blue: b}, + a === undefined ? {} : {opacity: a}, + hex, + ColorpickerDialog.convertRgbToHsl(r, g, b) + ); + this._updateCssColor(); + }, + /** + * Updates colors according to given HSL values. + * + * @private + * @param {integer} h + * @param {integer} s + * @param {integer} l + */ + _updateHsl: function (h, s, l) { + var rgb = ColorpickerDialog.convertHslToRgb(h, s, l); + if (!rgb) { + return; + } + _.extend(this.colorComponents, + {hue: h, saturation: s, lightness: l}, + rgb, + ColorpickerDialog.convertRgbToHex(rgb.red, rgb.green, rgb.blue) + ); + this._updateCssColor(); + }, + /** + * Updates color opacity. + * + * @private + * @param {integer} a + */ + _updateOpacity: function (a) { + if (a < 0 || a > 100) { + return; + } + _.extend(this.colorComponents, + {opacity: a} + ); + this._updateCssColor(); + }, + /** + * Updates css color representation. + * + * @private + */ + _updateCssColor: function () { + _.extend(this.colorComponents, + {cssColor: ColorpickerDialog.formatColor(this.colorComponents)} + ); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Updates color when the user starts clicking on the picker. + * + * @private + * @param {Event} ev + */ + _onMouseDownPicker: function (ev) { + this.pickerFlag = true; + this._onMouseMovePicker(ev); + }, + /** + * Updates saturation and lightness values on mouse drag over picker. + * + * @private + * @param {Event} ev + */ + _onMouseMovePicker: function (ev) { + if (!this.pickerFlag) { + return; + } + + var offset = this.$colorpickerArea.offset(); + var top = ev.pageY - offset.top; + var left = ev.pageX - offset.left; + var saturation = Math.round(100 * left / this.$colorpickerArea.width()); + var lightness = Math.round(100 * (this.$colorpickerArea.height() - top) / this.$colorpickerArea.height()); + saturation = utils.confine(saturation, 0, 100); + lightness = utils.confine(lightness, 0, 100); + + this._updateHsl(this.colorComponents.hue, saturation, lightness); + this._updateUI(); + }, + /** + * Updates color when user starts clicking on slider. + * + * @private + * @param {Event} ev + */ + _onMouseDownSlider: function (ev) { + this.sliderFlag = true; + this._onMouseMoveSlider(ev); + }, + /** + * Updates hue value on mouse drag over slider. + * + * @private + * @param {Event} ev + */ + _onMouseMoveSlider: function (ev) { + if (!this.sliderFlag) { + return; + } + + var y = ev.pageY - this.$colorSlider.offset().top; + var hue = Math.round(360 * y / this.$colorSlider.height()); + hue = utils.confine(hue, 0, 360); + + this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness); + this._updateUI(); + }, + /** + * Updates opacity when user starts clicking on opacity slider. + * + * @private + * @param {Event} ev + */ + _onMouseDownOpacitySlider: function (ev) { + this.opacitySliderFlag = true; + this._onMouseMoveOpacitySlider(ev); + }, + /** + * Updates opacity value on mouse drag over opacity slider. + * + * @private + * @param {Event} ev + */ + _onMouseMoveOpacitySlider: function (ev) { + if (!this.opacitySliderFlag) { + return; + } + + var y = ev.pageY - this.$opacitySlider.offset().top; + var opacity = Math.round(100 * (1 - y / this.$opacitySlider.height())); + opacity = utils.confine(opacity, 0, 100); + + this._updateOpacity(opacity); + this._updateUI(); + }, + /** + * Called when input value is changed -> Updates UI: Set picker and slider + * position and set colors. + * + * @private + * @param {Event} ev + */ + _onChangeInputs: function (ev) { + switch ($(ev.target).data('colorMethod')) { + case 'hex': + this._updateHex(this.$('.o_hex_input').val()); + break; + case 'rgb': + this._updateRgba( + parseInt(this.$('.o_red_input').val()), + parseInt(this.$('.o_green_input').val()), + parseInt(this.$('.o_blue_input').val()) + ); + break; + case 'hsl': + this._updateHsl( + parseInt(this.$('.o_hue_input').val()), + parseInt(this.$('.o_saturation_input').val()), + parseInt(this.$('.o_lightness_input').val()) + ); + break; + case 'opacity': + this._updateOpacity(parseInt(this.$('.o_opacity_input').val())); + break; + } + this._updateUI(); + }, + /** + * @private + */ + _onFinalPick: function () { + this.trigger_up('colorpicker:saved', this.colorComponents); + }, +}); + +//-------------------------------------------------------------------------- +// 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(rgba[1]), + green: parseInt(rgba[2]), + blue: parseInt(rgba[3]), + opacity: parseInt(rgba[4]), + }; + } 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; + } + 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)); + } + } + 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/translator.xml b/addons/web_editor/static/src/xml/translator.xml deleted file mode 100644 index dbd6c8287f6..00000000000 --- a/addons/web_editor/static/src/xml/translator.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<templates id="template" xml:space="preserve"> -<div t-name="web_editor.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"> - <li data-oe-translation-state="to_translate">Content to translate</li> - <li data-oe-translation-state="translated">Translated content</li> - </ul> - <p> - In this mode, you can only translate texts. To change the structure of the page, you must edit the master page. - Each modification on the master page is automatically applied to all translated versions. - </p> -</div> -</templates> 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> |
