diff options
| author | Robot Odoo <xmo+roboto@odoo.com> | 2019-01-17 19:17:22 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-01-17 19:17:22 +0100 |
| commit | 22efdfbecc1e8c688000be98903a5a355d1ece8a (patch) | |
| tree | e097625171b2b67ed5f5288ced67446608b9f633 /addons/web_editor/static/lib/summernote/plugin | |
| parent | f50fbc6d168f0d17fa1e932b4ecc968a92b02b65 (diff) | |
| parent | eb0ea8efbe932c140302e9f8da33a839bcd73146 (diff) | |
[IMP] web_editor,*: Refactoring the wysiwyg editor and 'html' field
_Task #1878502_
- The refactoring allows for the update of the _Summernote_ library.
- A new structure was created by transforming all the plugins in the library using the odoo inheritance system. Plugins are easier to implement with the `AbstractPlugin` to add Odoo behaviors.
- The cleanup allowed us to remove some of the code that was in `web_editor` but only used in `website` or in `mass_mailing`. Some parts are still in `web_editor` but will be moved at a later time.
- The refactoring also had to be done in order to create a set of unit tests. Each behavior can be tested, including the behaviors performed as a consequence of keyboard interactions (for instance: Enter, Tab...).
- From now on, the methods of the library (in this case _Summernote_) can no longer be called by other modules or files. Only the `wysiwyg` widgets can access it, to simplify the updating process. The `wysiwyg` object serves as an interface. A lot of tests are done, ensuring the consistency of outside behavior.
- Depending on the options the snippets will be loaded or not, the editor will be in an iframe or not... all of this is transparent from the outside.
- Regarding iframes, all controllers related to editing have been removed: the new API no longer needs them. This speeds up loading, eases testing and removes complexity for the same features.
**PUBLIC FEATURES**
There are several public methods on the `Wysiwyg` class:
- `Wysiwyg.prepare (WidgetParent)` which returns a deferred resolved when the library (xml, lazy, assets...) is loaded.
- `Wysiwyg.getRange (DOM)` returns the range (selection in the dom)
- `Wysiwyg.setRange (startNode, startOffset, endNode, endOffset)` which creates a range (selection in the dom)
- `Wysiwyg.setRangeFromNode (DOM, options)` that creates a range from an element (option available to select all, start or end)
A jQuery selector was added: `:o_editable`, which indicates whether the current element is editable. That is, if it is contained in a tag with the attribute `contentEditable = "true"` or in a tag with the class `o_editable`.
Several methods are also present:
- `focusIn`: makes a focus and places the cursor at the beginning of the element
- `focusInEnd`: makes a focus and places the cursor at the end of the element
- `selectContent`: makes a focus and selects the content
**HTML FIELD**
The HTML field can receive different options:
- `style-inline:` {boolean} transforms a class into an inline style when saving and vice versa when reading.
- `no-attachment`: {boolean} prevents the use of attachments (in media dialog)
- `cssEdit`: {xml_id} to use a template containing the css to loaded in an iframe when editing
- `cssReadonly`: {xml_id} to use a template containing the css to load into an iframe when viewing in readonly
- `snippets`: {xml_id} snippets template (can be used with or without `cssEdit`)
- `wrapper`: {template} qweb static template (containing a tag: `id = "wrapper"`) that will include the content during editing (removed on save)
**MASS MAILING**
A widget was created for `mass mailing`. There are now two fields: `body_html` and `body_arch`.
`body_arch` contains the code with the class without conversion into inline style, useful when editing and one with the inline style that is visible in readonly mode and sent by email.
Advantage: no spreading errors, able to update css/theme, able to do more changes when converting to inline style so that a maximum of mail clients have an impeccable rendering.
**REMAINING KNOW ISSUES**
These will be fixed in a subsequent commit.
- A few committed changes made during the development of this refactoring were lost in the process and should be reapplied.
- Some minor (mostly range) issues remain with Firefox.
--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr
closes odoo/odoo#29775
Diffstat (limited to 'addons/web_editor/static/lib/summernote/plugin')
4 files changed, 701 insertions, 0 deletions
diff --git a/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.css b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.css new file mode 100755 index 00000000000..6232dde991f --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.css @@ -0,0 +1,16 @@ +.ext-databasic { + position: relative; + display: block; + min-height: 50px; + background-color: cyan; + text-align: center; + padding: 20px; + border: 1px solid white; + border-radius: 10px; +} + +.ext-databasic p { + color: white; + font-size: 1.2em; + margin: 0; +} diff --git a/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.js b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.js new file mode 100755 index 00000000000..efe0d4dc33e --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/databasic/summernote-ext-databasic.js @@ -0,0 +1,292 @@ +(function(factory) { + /* global define */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === 'object' && module.exports) { + // Node/CommonJS + module.exports = factory(require('jquery')); + } else { + // Browser globals + factory(window.jQuery); + } +}(function($) { + // pull in some summernote core functions + var ui = $.summernote.ui; + var dom = $.summernote.dom; + + // define the popover plugin + var DataBasicPlugin = function(context) { + var self = this; + var options = context.options; + var lang = options.langInfo; + + self.icon = '<i class="fa fa-object-group"/>'; + + // add context menu button for dialog + context.memo('button.databasic', function() { + return ui.button({ + contents: self.icon, + tooltip: lang.databasic.insert, + click: context.createInvokeHandler('databasic.showDialog') + }).render(); + }); + + // add popover edit button + context.memo('button.databasicDialog', function() { + return ui.button({ + contents: self.icon, + tooltip: lang.databasic.edit, + click: context.createInvokeHandler('databasic.showDialog') + }).render(); + }); + + // add popover size buttons + context.memo('button.databasicSize100', function() { + return ui.button({ + contents: '<span class="note-fontsize-10">100%</span>', + tooltip: lang.image.resizeFull, + click: context.createInvokeHandler('editor.resize', '1') + }).render(); + }); + context.memo('button.databasicSize50', function() { + return ui.button({ + contents: '<span class="note-fontsize-10">50%</span>', + tooltip: lang.image.resizeHalf, + click: context.createInvokeHandler('editor.resize', '0.5') + }).render(); + }); + context.memo('button.databasicSize25', function() { + return ui.button({ + contents: '<span class="note-fontsize-10">25%</span>', + tooltip: lang.image.resizeQuarter, + click: context.createInvokeHandler('editor.resize', '0.25') + }).render(); + }); + + self.events = { + 'summernote.init': function(we, e) { + // update existing containers + $('data.ext-databasic', e.editable).each(function() { self.setContent($(this)); }); + // TODO: make this an undo snapshot... + }, + 'summernote.keyup summernote.mouseup summernote.change summernote.scroll': function() { + self.update(); + }, + 'summernote.dialog.shown': function() { + self.hidePopover(); + } + }; + + self.initialize = function() { + // create dialog markup + var $container = options.dialogsInBody ? $(document.body) : context.layoutInfo.editor; + + var body = '<div class="form-group row-fluid">' + + '<label>' + lang.databasic.testLabel + '</label>' + + '<input class="ext-databasic-test form-control" type="text" />' + + '</div>'; + var footer = '<button href="#" class="btn btn-primary ext-databasic-save">' + lang.databasic.insert + '</button>'; + + self.$dialog = ui.dialog({ + title: lang.databasic.name, + fade: options.dialogsFade, + body: body, + footer: footer + }).render().appendTo($container); + + // create popover + self.$popover = ui.popover({ + className: 'ext-databasic-popover' + }).render().appendTo('body'); + var $content = self.$popover.find('.popover-content'); + + context.invoke('buttons.build', $content, options.popover.databasic); + }; + + self.destroy = function() { + self.$popover.remove(); + self.$popover = null; + self.$dialog.remove(); + self.$dialog = null; + }; + + self.update = function() { + // Prevent focusing on editable when invoke('code') is executed + if (!context.invoke('editor.hasFocus')) { + self.hidePopover(); + return; + } + + var rng = context.invoke('editor.createRange'); + var visible = false; + + if (rng.isOnData()) { + var $data = $(rng.sc).closest('data.ext-databasic'); + + if ($data.length) { + var pos = dom.posFromPlaceholder($data[0]); + + self.$popover.css({ + display: 'block', + left: pos.left, + top: pos.top + }); + + // save editor target to let size buttons resize the container + context.invoke('editor.saveTarget', $data[0]); + + visible = true; + } + } + + // hide if not visible + if (!visible) { + self.hidePopover(); + } + }; + + self.hidePopover = function() { + self.$popover.hide(); + }; + + // define plugin dialog + self.getInfo = function() { + var rng = context.invoke('editor.createRange'); + + if (rng.isOnData()) { + var $data = $(rng.sc).closest('data.ext-databasic'); + + if ($data.length) { + // Get the first node on range(for edit). + return { + node: $data, + test: $data.attr('data-test') + }; + } + } + + return {}; + }; + + self.setContent = function($node) { + $node.html('<p contenteditable="false">' + self.icon + ' ' + lang.databasic.name + ': ' + + $node.attr('data-test') + '</p>'); + }; + + self.updateNode = function(info) { + self.setContent(info.node + .attr('data-test', info.test)); + }; + + self.createNode = function(info) { + var $node = $('<data class="ext-databasic"></data>'); + + if ($node) { + // save node to info structure + info.node = $node; + // insert node into editor dom + context.invoke('editor.insertNode', $node[0]); + } + + return $node; + }; + + self.showDialog = function() { + var info = self.getInfo(); + var newNode = !info.node; + context.invoke('editor.saveRange'); + + self + .openDialog(info) + .then(function(dialogInfo) { + // [workaround] hide dialog before restore range for IE range focus + ui.hideDialog(self.$dialog); + context.invoke('editor.restoreRange'); + + // insert a new node + if (newNode) { + self.createNode(info); + } + + // update info with dialog info + $.extend(info, dialogInfo); + + self.updateNode(info); + }) + .fail(function() { + context.invoke('editor.restoreRange'); + }); + }; + + self.openDialog = function(info) { + return $.Deferred(function(deferred) { + var $inpTest = self.$dialog.find('.ext-databasic-test'); + var $saveBtn = self.$dialog.find('.ext-databasic-save'); + var onKeyup = function(event) { + if (event.keyCode === 13) { + $saveBtn.trigger('click'); + } + }; + + ui.onDialogShown(self.$dialog, function() { + context.triggerEvent('dialog.shown'); + + $inpTest.val(info.test).on('input', function() { + ui.toggleBtn($saveBtn, $inpTest.val()); + }).trigger('focus').on('keyup', onKeyup); + + $saveBtn + .text(info.node ? lang.databasic.edit : lang.databasic.insert) + .click(function(event) { + event.preventDefault(); + + deferred.resolve({ test: $inpTest.val() }); + }); + + // init save button + ui.toggleBtn($saveBtn, $inpTest.val()); + }); + + ui.onDialogHidden(self.$dialog, function() { + $inpTest.off('input keyup'); + $saveBtn.off('click'); + + if (deferred.state() === 'pending') { + deferred.reject(); + } + }); + + ui.showDialog(self.$dialog); + }); + }; + }; + + // Extends summernote + $.extend(true, $.summernote, { + plugins: { + databasic: DataBasicPlugin + }, + + options: { + popover: { + databasic: [ + ['databasic', ['databasicDialog', 'databasicSize100', 'databasicSize50', 'databasicSize25']] + ] + } + }, + + // add localization texts + lang: { + 'en-US': { + databasic: { + name: 'Basic Data Container', + insert: 'insert basic data container', + edit: 'edit basic data container', + testLabel: 'test input' + } + } + } + + }); +})); diff --git a/addons/web_editor/static/lib/summernote/plugin/hello/summernote-ext-hello.js b/addons/web_editor/static/lib/summernote/plugin/hello/summernote-ext-hello.js new file mode 100644 index 00000000000..22e4f6ac034 --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/hello/summernote-ext-hello.js @@ -0,0 +1,81 @@ +(function(factory) { + /* global define */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === 'object' && module.exports) { + // Node/CommonJS + module.exports = factory(require('jquery')); + } else { + // Browser globals + factory(window.jQuery); + } +}(function($) { + // Extends plugins for adding hello. + // - plugin is external module for customizing. + $.extend($.summernote.plugins, { + /** + * @param {Object} context - context object has status of editor. + */ + 'hello': function(context) { + var self = this; + + // ui has renders to build ui elements. + // - you can create a button with `ui.button` + var ui = $.summernote.ui; + + // add hello button + context.memo('button.hello', function() { + // create button + var button = ui.button({ + contents: '<i class="fa fa-child"/> Hello', + tooltip: 'hello', + click: function() { + self.$panel.show(); + self.$panel.hide(500); + // invoke insertText method with 'hello' on editor module. + context.invoke('editor.insertText', 'hello'); + } + }); + + // create jQuery object from button instance. + var $hello = button.render(); + return $hello; + }); + + // This events will be attached when editor is initialized. + this.events = { + // This will be called after modules are initialized. + 'summernote.init': function(we, e) { + console.log('summernote initialized', we, e); + }, + // This will be called when user releases a key on editable. + 'summernote.keyup': function(we, e) { + console.log('summernote keyup', we, e); + } + }; + + // This method will be called when editor is initialized by $('..').summernote(); + // You can create elements for plugin + this.initialize = function() { + this.$panel = $('<div class="hello-panel"/>').css({ + position: 'absolute', + width: 100, + height: 100, + left: '50%', + top: '50%', + background: 'red' + }).hide(); + + this.$panel.appendTo('body'); + }; + + // This methods will be called when editor is destroyed by $('..').summernote('destroy'); + // You should remove elements on `initialize`. + this.destroy = function() { + this.$panel.remove(); + this.$panel = null; + }; + } + }); +})); diff --git a/addons/web_editor/static/lib/summernote/plugin/specialchars/summernote-ext-specialchars.js b/addons/web_editor/static/lib/summernote/plugin/specialchars/summernote-ext-specialchars.js new file mode 100644 index 00000000000..2ee4e512efd --- /dev/null +++ b/addons/web_editor/static/lib/summernote/plugin/specialchars/summernote-ext-specialchars.js @@ -0,0 +1,312 @@ +(function(factory) { + /* global define */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === 'object' && module.exports) { + // Node/CommonJS + module.exports = factory(require('jquery')); + } else { + // Browser globals + factory(window.jQuery); + } +}(function($) { + $.extend($.summernote.plugins, { + 'specialchars': function(context) { + var self = this; + var ui = $.summernote.ui; + + var $editor = context.layoutInfo.editor; + var options = context.options; + var lang = options.langInfo; + + var KEY = { + UP: 38, + DOWN: 40, + LEFT: 37, + RIGHT: 39, + ENTER: 13 + }; + var COLUMN_LENGTH = 15; + var COLUMN_WIDTH = 35; + + var currentColumn = 0; + var currentRow = 0; + var totalColumn = 0; + var totalRow = 0; + + // special characters data set + var specialCharDataSet = [ + '"', '&', '<', '>', '¡', '¢', + '£', '¤', '¥', '¦', '§', + '¨', '©', 'ª', '«', '¬', + '®', '¯', '°', '±', '²', + '³', '´', 'µ', '¶', '·', + '¸', '¹', 'º', '»', '¼', + '½', '¾', '¿', '×', '÷', + 'ƒ', 'ˆ', '˜', '–', '—', + '‘', '’', '‚', '“', '”', + '„', '†', '‡', '•', '…', + '‰', '′', '″', '‹', '›', + '‾', '⁄', '€', 'ℑ', '℘', + 'ℜ', '™', 'ℵ', '←', '↑', + '→', '↓', '↔', '↵', '⇐', + '⇑', '⇒', '⇓', '⇔', '∀', + '∂', '∃', '∅', '∇', '∈', + '∉', '∋', '∏', '∑', '−', + '∗', '√', '∝', '∞', '∠', + '∧', '∨', '∩', '∪', '∫', + '∴', '∼', '≅', '≈', '≠', + '≡', '≤', '≥', '⊂', '⊃', + '⊄', '⊆', '⊇', '⊕', '⊗', + '⊥', '⋅', '⌈', '⌉', '⌊', + '⌋', '◊', '♠', '♣', '♥', + '♦' + ]; + + context.memo('button.specialchars', function() { + return ui.button({ + contents: '<i class="fa fa-font fa-flip-vertical">', + tooltip: lang.specialChar.specialChar, + click: function() { + self.show(); + } + }).render(); + }); + + /** + * Make Special Characters Table + * + * @member plugin.specialChar + * @private + * @return {jQuery} + */ + this.makeSpecialCharSetTable = function() { + var $table = $('<table/>'); + $.each(specialCharDataSet, function(idx, text) { + var $td = $('<td/>').addClass('note-specialchar-node'); + var $tr = (idx % COLUMN_LENGTH === 0) ? $('<tr/>') : $table.find('tr').last(); + + var $button = ui.button({ + callback: function($node) { + $node.html(text); + $node.attr('title', text); + $node.attr('data-value', encodeURIComponent(text)); + $node.css({ + width: COLUMN_WIDTH, + 'margin-right': '2px', + 'margin-bottom': '2px' + }); + } + }).render(); + + $td.append($button); + + $tr.append($td); + if (idx % COLUMN_LENGTH === 0) { + $table.append($tr); + } + }); + + totalRow = $table.find('tr').length; + totalColumn = COLUMN_LENGTH; + + return $table; + }; + + this.initialize = function() { + var $container = options.dialogsInBody ? $(document.body) : $editor; + + var body = '<div class="form-group row-fluid">' + this.makeSpecialCharSetTable()[0].outerHTML + '</div>'; + + this.$dialog = ui.dialog({ + title: lang.specialChar.select, + body: body + }).render().appendTo($container); + }; + + this.show = function() { + var text = context.invoke('editor.getSelectedText'); + context.invoke('editor.saveRange'); + this.showSpecialCharDialog(text).then(function(selectChar) { + context.invoke('editor.restoreRange'); + + // build node + var $node = $('<span></span>').html(selectChar)[0]; + + if ($node) { + // insert video node + context.invoke('editor.insertNode', $node); + } + }).fail(function() { + context.invoke('editor.restoreRange'); + }); + }; + + /** + * show image dialog + * + * @param {jQuery} $dialog + * @return {Promise} + */ + this.showSpecialCharDialog = function(text) { + return $.Deferred(function(deferred) { + var $specialCharDialog = self.$dialog; + var $specialCharNode = $specialCharDialog.find('.note-specialchar-node'); + var $selectedNode = null; + var ARROW_KEYS = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT]; + var ENTER_KEY = KEY.ENTER; + + function addActiveClass($target) { + if (!$target) { + return; + } + $target.find('button').addClass('active'); + $selectedNode = $target; + } + + function removeActiveClass($target) { + $target.find('button').removeClass('active'); + $selectedNode = null; + } + + // find next node + function findNextNode(row, column) { + var findNode = null; + $.each($specialCharNode, function(idx, $node) { + var findRow = Math.ceil((idx + 1) / COLUMN_LENGTH); + var findColumn = ((idx + 1) % COLUMN_LENGTH === 0) ? COLUMN_LENGTH : (idx + 1) % COLUMN_LENGTH; + if (findRow === row && findColumn === column) { + findNode = $node; + return false; + } + }); + return $(findNode); + } + + function arrowKeyHandler(keyCode) { + // left, right, up, down key + var $nextNode; + var lastRowColumnLength = $specialCharNode.length % totalColumn; + + if (KEY.LEFT === keyCode) { + if (currentColumn > 1) { + currentColumn = currentColumn - 1; + } else if (currentRow === 1 && currentColumn === 1) { + currentColumn = lastRowColumnLength; + currentRow = totalRow; + } else { + currentColumn = totalColumn; + currentRow = currentRow - 1; + } + } else if (KEY.RIGHT === keyCode) { + if (currentRow === totalRow && lastRowColumnLength === currentColumn) { + currentColumn = 1; + currentRow = 1; + } else if (currentColumn < totalColumn) { + currentColumn = currentColumn + 1; + } else { + currentColumn = 1; + currentRow = currentRow + 1; + } + } else if (KEY.UP === keyCode) { + if (currentRow === 1 && lastRowColumnLength < currentColumn) { + currentRow = totalRow - 1; + } else { + currentRow = currentRow - 1; + } + } else if (KEY.DOWN === keyCode) { + currentRow = currentRow + 1; + } + + if (currentRow === totalRow && currentColumn > lastRowColumnLength) { + currentRow = 1; + } else if (currentRow > totalRow) { + currentRow = 1; + } else if (currentRow < 1) { + currentRow = totalRow; + } + + $nextNode = findNextNode(currentRow, currentColumn); + + if ($nextNode) { + removeActiveClass($selectedNode); + addActiveClass($nextNode); + } + } + + function enterKeyHandler() { + if (!$selectedNode) { + return; + } + + deferred.resolve(decodeURIComponent($selectedNode.find('button').attr('data-value'))); + $specialCharDialog.modal('hide'); + } + + function keyDownEventHandler(event) { + event.preventDefault(); + var keyCode = event.keyCode; + if (keyCode === undefined || keyCode === null) { + return; + } + // check arrowKeys match + if (ARROW_KEYS.indexOf(keyCode) > -1) { + if ($selectedNode === null) { + addActiveClass($specialCharNode.eq(0)); + currentColumn = 1; + currentRow = 1; + return; + } + arrowKeyHandler(keyCode); + } else if (keyCode === ENTER_KEY) { + enterKeyHandler(); + } + return false; + } + + // remove class + removeActiveClass($specialCharNode); + + // find selected node + if (text) { + for (var i = 0; i < $specialCharNode.length; i++) { + var $checkNode = $($specialCharNode[i]); + if ($checkNode.text() === text) { + addActiveClass($checkNode); + currentRow = Math.ceil((i + 1) / COLUMN_LENGTH); + currentColumn = (i + 1) % COLUMN_LENGTH; + } + } + } + + ui.onDialogShown(self.$dialog, function() { + $(document).on('keydown', keyDownEventHandler); + + self.$dialog.find('button').tooltip(); + + $specialCharNode.on('click', function(event) { + event.preventDefault(); + deferred.resolve(decodeURIComponent($(event.currentTarget).find('button').attr('data-value'))); + ui.hideDialog(self.$dialog); + }); + }); + + ui.onDialogHidden(self.$dialog, function() { + $specialCharNode.off('click'); + + self.$dialog.find('button').tooltip('destroy'); + + $(document).off('keydown', keyDownEventHandler); + + if (deferred.state() === 'pending') { + deferred.reject(); + } + }); + + ui.showDialog(self.$dialog); + }); + }; + } + }); +})); |
