diff options
Diffstat (limited to 'addons/web_editor/static/src')
36 files changed, 5530 insertions, 10070 deletions
diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/base.js new file mode 100644 index 00000000000..d69cace3100 --- /dev/null +++ b/addons/web_editor/static/src/js/base.js @@ -0,0 +1,157 @@ +odoo.define('web_editor.base', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var session = require('web.session'); + +var domReady = $.Deferred(); +$(domReady.resolve.bind(domReady)); + +return { + /** + * Retrieves all the CSS rules which match the given parser (Regex). + * + * @param {Regex} filter + * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is + * defined by 3 values: 'selector', 'css' and 'names'. 'selector' + * is a string which contains the whole selector, 'css' is a string + * which contains the css properties and 'names' is an array of the + * first captured groups for each selector part. E.g.: if the + * filter is set to match .fa-* rules and capture the icon names, + * the rule: + * '.fa-alias1::before, .fa-alias2::before { hello: world; }' + * will be retrieved as + * { + * selector: '.fa-alias1::before, .fa-alias2::before', + * css: 'hello: world;', + * names: ['.fa-alias1', '.fa-alias2'], + * } + */ + cacheCssSelectors: {}, + getCssSelectors: function (filter) { + if (this.cacheCssSelectors[filter]) { + return this.cacheCssSelectors[filter]; + } + this.cacheCssSelectors[filter] = []; + var sheets = document.styleSheets; + for (var i = 0; i < sheets.length; i++) { + var rules; + try { + // try...catch because Firefox not able to enumerate + // document.styleSheets[].cssRules[] for cross-domain + // stylesheets. + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (!rules) { + continue; + } + + for (var r = 0 ; r < rules.length ; r++) { + var selectorText = rules[r].selectorText; + if (!selectorText) { + continue; + } + var selectors = selectorText.split(/\s*,\s*/); + var data = null; + for (var s = 0; s < selectors.length; s++) { + var match = selectors[s].trim().match(filter); + if (!match) { + continue; + } + if (!data) { + data = { + selector: match[0], + css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''), + names: [match[1]] + }; + } else { + data.selector += (', ' + match[0]); + data.names.push(match[1]); + } + } + if (data) { + this.cacheCssSelectors[filter].push(data); + } + } + } + return this.cacheCssSelectors[filter]; + }, + /** + * List of font icons to load by editor. The icons are displayed in the media + * editor and identified like font and image (can be colored, spinned, resized + * with fa classes). + * To add font, push a new object {base, parser} + * + * - base: class who appear on all fonts + * - parser: regular expression used to select all font in css stylesheets + * + * @type Array + */ + fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}], + /** + * Searches the fonts described by the @see fontIcons variable. + */ + computeFonts: _.once(function () { + var self = this; + _.each(this.fontIcons, function (data) { + data.cssData = self.getCssSelectors(data.parser); + 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/common/ace.js b/addons/web_editor/static/src/js/common/ace.js index dc2d78a53c7..37c21f2aa36 100644 --- a/addons/web_editor/static/src/js/common/ace.js +++ b/addons/web_editor/static/src/js/common/ace.js @@ -2,12 +2,13 @@ odoo.define('web_editor.ace', function (require) { 'use strict'; var ajax = require('web.ajax'); -var config = require('web.config'); var concurrency = require('web.concurrency'); 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'); var _t = core._t; @@ -119,22 +120,20 @@ var ViewEditor = Widget.extend({ jsLibs: [ '/web/static/lib/ace/ace.js', [ - '/web/static/lib/ace/javascript_highlight_rules.js', '/web/static/lib/ace/mode-xml.js', '/web/static/lib/ace/mode-scss.js', - '/web/static/lib/ace/mode-js.js', '/web/static/lib/ace/theme-monokai.js' ] ], events: { 'click .o_ace_type_switcher_choice': '_onTypeChoice', 'change .o_res_list': '_onResChange', - 'click .o_ace_filter': '_onFilterChange', + 'click .js_include_bundles': '_onIncludeBundlesChange', + 'click .js_include_all_scss': '_onIncludeAllSCSSChange', 'click button[data-action=save]': '_onSaveClick', 'click button[data-action=reset]': '_onResetClick', 'click button[data-action=format]': '_onFormatClick', 'click button[data-action=close]': '_onCloseClick', - 'click #ace-view-id > .alert-warning .close': '_onCloseWarningClick' }, /** @@ -152,35 +151,30 @@ var ViewEditor = Widget.extend({ * @param {string} [options.position=right] * @param {boolean} [options.doNotLoadViews=false] * @param {boolean} [options.doNotLoadSCSS=false] - * @param {boolean} [options.doNotLoadJS=false] * @param {boolean} [options.includeBundles=false] - * @param {string} [options.filesFilter=custom] + * @param {boolean} [options.includeAllSCSS=false] * @param {string[]} [options.defaultBundlesRestriction] */ init: function (parent, viewKey, options) { this._super.apply(this, arguments); - this.context = options.context; - this.viewKey = viewKey; this.options = _.defaults({}, options, { position: 'right', doNotLoadViews: false, doNotLoadSCSS: false, - doNotLoadJS: false, includeBundles: false, - filesFilter: 'custom', + includeAllSCSS: false, defaultBundlesRestriction: [], }); - this.resources = {xml: {}, scss: {}, js: {}}; - this.editingSessions = {xml: {}, scss: {}, js: {}}; + this.resources = {xml: {}, scss: {}}; + this.editingSessions = {xml: {}, scss: {}}; this.currentType = 'xml'; // Alias this.views = this.resources.xml; this.scss = this.resources.scss; - this.js = this.resources.js; }, /** * Loads everything the ace library needs to work. @@ -189,10 +183,11 @@ var ViewEditor = Widget.extend({ * @override */ willStart: function () { - return Promise.all([ + return $.when( this._super.apply(this, arguments), + ajax.loadLibs(this), this._loadResources() - ]); + ); }, /** * Initializes the library and initial view once the DOM is ready. It also @@ -208,13 +203,11 @@ var ViewEditor = Widget.extend({ this.$lists = { xml: this.$('#ace-view-list'), - scss: this.$('#ace-scss-list'), - js: this.$('#ace-js-list'), + scss: this.$('#ace-scss-list') }; this.$includeBundlesArea = this.$('.oe_include_bundles'); this.$includeAllSCSSArea = this.$('.o_include_all_scss'); this.$viewID = this.$('#ace-view-id > span'); - this.$warningMessage = this.$('#ace-view-id > .alert-warning'); this.$formatButton = this.$('button[data-action=format]'); this.$resetButton = this.$('button[data-action=reset]'); @@ -234,24 +227,12 @@ var ViewEditor = Widget.extend({ var initType; if (this.options.initialResID) { initResID = this.options.initialResID; - if (_.isString(initResID) && initResID[0] === '/') { - if (_.str.endsWith(initResID, '.scss')) { - initType = 'scss'; - } else { - initType = 'js'; - } - } else { - initType = 'xml'; - } + initType = (_.isString(initResID) && initResID[0] === '/') ? 'scss' : 'xml'; } else { if (!this.options.doNotLoadSCSS) { initResID = this.sortedSCSS[0][1][0].url; // first bundle, scss files, first one initType = 'scss'; } - if (!this.options.doNotLoadJS) { - initResID = this.sortedJS[0][1][0].url; // first bundle, js files, first one - initType = 'js'; - } if (!this.options.doNotLoadViews) { if (typeof this.viewKey === "number") { initResID = this.viewKey; @@ -339,7 +320,7 @@ var ViewEditor = Widget.extend({ * Initializes a text editor for the specified resource. * * @private - * @param {integer|string} resID - the ID/URL of the view/scss/js file + * @param {integer|string} resID - the ID/URL of the view/scss file * @param {string} [type] (default to the currently selected one) * @returns {ace.EditSession} */ @@ -359,13 +340,13 @@ var ViewEditor = Widget.extend({ return editingSession; }, /** - * Forces the view/scss/js file identified by its ID/URL to be displayed in the + * Forces the view/scss file identified by its ID/URL to be displayed in the * editor. The method will update the resource select DOM element as well if * necessary. * * @private * @param {integer|string} resID - * @param {string} [type] - the type of resource (either 'xml', 'scss' or 'js') + * @param {string} [type] - the type of resource (either 'xml' or 'scss') */ _displayResource: function (resID, type) { if (type) { @@ -378,29 +359,14 @@ var ViewEditor = Widget.extend({ } this.aceEditor.setSession(editingSession); - var isCustomized = false; if (this.currentType === 'xml') { this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key)); - } else if (this.currentType === 'scss') { - isCustomized = this.scss[resID].customized; - this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID)); } else { - isCustomized = this.js[resID].customized; - this.$viewID.text(_.str.sprintf(_t("JS file: %s"), resID)); + this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID)); } this.$lists[this.currentType].select2('val', resID); - this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !isCustomized); - - // TODO the warning message is always shown for XML templates but: - // 1) We have to implement a way to be able to reset XML templates - // otherwise the warning message is not accurate - // 2) We should be able to detect if the XML template is customized to - // not show the warning in that case - this.$warningMessage.toggleClass('d-none', - this.currentType !== 'xml' && (resID.indexOf('/user_custom_') >= 0 || isCustomized)); - - this.aceEditor.resize(true); + this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !this.scss[resID].customized); }, /** * Formats the current resource being vizualized. @@ -432,15 +398,14 @@ var ViewEditor = Widget.extend({ * is loading the activate views, index them and build their hierarchy. * * @private - * @returns {Promise} + * @returns {Deferred} */ _loadResources: function () { // Reset resources - this.resources = {xml: {}, scss: {}, js: {}}; - this.editingSessions = {xml: {}, scss: {}, js: {}}; + this.resources = {xml: {}, scss: {}}; + this.editingSessions = {xml: {}, scss: {}}; this.views = this.resources.xml; this.scss = this.resources.scss; - this.js = this.resources.js; // Load resources return this._rpc({ @@ -449,15 +414,12 @@ var ViewEditor = Widget.extend({ key: this.viewKey, get_views: !this.options.doNotLoadViews, get_scss: !this.options.doNotLoadSCSS, - get_js: !this.options.doNotLoadJS, bundles: this.options.includeBundles, - bundles_restriction: this.options.filesFilter === 'all' ? [] : this.options.defaultBundlesRestriction, - only_user_custom_files: this.options.filesFilter === 'custom', + bundles_restriction: this.options.includeAllSCSS ? [] : this.options.defaultBundlesRestriction, }, }).then((function (resources) { _processViews.call(this, resources.views || []); - _processJSorSCSS.call(this, resources.scss || [], 'scss'); - _processJSorSCSS.call(this, resources.js || [], 'js'); + _processSCSS.call(this, resources.scss || []); }).bind(this)); function _processViews(views) { @@ -498,24 +460,20 @@ var ViewEditor = Widget.extend({ }); } - function _processJSorSCSS(data, type) { - // The received scss or js data is already sorted by bundle and DOM order - if (type === 'scss') { - this.sortedSCSS = data; - } else { - this.sortedJS = data; - } + function _processSCSS(scss) { + // The received scss data is already sorted by bundle and DOM order + this.sortedSCSS = scss; // Store the URL ungrouped by bundle and use the URL as key (resource ID) - var resources = type === 'scss' ? this.scss : this.js; - _.each(data, function (bundleInfos) { + var self = this; + _.each(scss, function (bundleInfos) { _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; }); - _.extend(resources, _.indexBy(bundleInfos[1], 'url')); + _.extend(self.scss, _.indexBy(bundleInfos[1], 'url')); }); } }, /** - * Forces the view/scss/js file identified by its ID/URL to be reset to the way + * Forces the view/scss file identified by its ID/URL to be reset to the way * it was before the user started editing it. * * @todo views reset is not supported yet @@ -523,57 +481,59 @@ var ViewEditor = Widget.extend({ * @private * @param {integer|string} [resID] (default to the currently selected one) * @param {string} [type] (default to the currently selected one) - * @returns {Promise} + * @returns {Deferred} */ _resetResource: function (resID, type) { resID = resID || this._getSelectedResource(); type = type || this.currentType; if (this.currentType === 'xml') { - return Promise.reject(_t("Reseting views is not supported yet")); + return $.Defered().reject(_t("Reseting views is not supported yet")); } else { - var resource = type === 'scss' ? this.scss[resID] : this.js[resID]; return this._rpc({ - route: '/web_editor/reset_asset', + route: '/web_editor/reset_scss', params: { url: resID, - bundle_xmlid: resource.bundle_xmlid, + bundle_xmlid: this.scss[resID].bundle_xmlid, }, }); } }, /** - * Saves a unique SCSS or JS file. + * Saves an unique SCSS file. * * @private * @param {Object} session - contains the 'id' (url) and the 'text' of the - * SCSS or JS file to save. - * @return {Promise} status indicates if the save is finished or if an + * SCSS file to save. + * @return {Deferred} status indicates if the save is finished or if an * error occured. */ - _saveSCSSorJS: function (session) { + _saveSCSS: function (session) { + var def = $.Deferred(); + var self = this; - var sessionIdEndsWithJS = _.string.endsWith(session.id, '.js'); - var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid; - var fileType = sessionIdEndsWithJS ? 'js' : 'scss'; - return self._rpc({ - route: '/web_editor/save_asset', + this._rpc({ + route: '/web_editor/save_scss', params: { url: session.id, - bundle_xmlid: bundleXmlID, + bundle_xmlid: this.scss[session.id].bundle_xmlid, content: session.text, - file_type: fileType, }, }).then(function () { - self._toggleDirtyInfo(session.id, fileType, false); + self._toggleDirtyInfo(session.id, 'scss', false); + def.resolve(); + }, function (source, error) { + def.reject(session, error); }); + + return def; }, /** * Saves every resource that has been modified. If one cannot be saved, none * is saved and an error message is displayed. * * @private - * @return {Promise} status indicates if the save is finished or if an + * @return {Deferred} status indicates if the save is finished or if an * error occured. */ _saveResources: function () { @@ -602,7 +562,7 @@ var ViewEditor = Widget.extend({ } } }).bind(this)); - if (errorFound) return Promise.reject(errorFound); + if (errorFound) return $.Deferred().reject(errorFound); var defs = []; var mutex = new concurrency.Mutex(); @@ -611,15 +571,13 @@ var ViewEditor = Widget.extend({ _toSave = _.sortBy(_toSave, 'id').reverse(); _.each(_toSave, function (session) { defs.push(mutex.exec(function () { - return (type === 'xml' ? self._saveView(session) : self._saveSCSSorJS(session)); + return (type === 'xml' ? self._saveView(session) : self._saveSCSS(session)); })); }); }).bind(this)); - var self = this; - return Promise.all(defs).guardedCatch(function (results) { - var error = results[1]; - Dialog.alert(self, '', { + return $.when.apply($, defs).fail((function (session, error) { + Dialog.alert(this, '', { title: _t("Server error"), $content: $('<div/>').html( _t("A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted.") @@ -627,32 +585,34 @@ var ViewEditor = Widget.extend({ + error ) }); - }); + }).bind(this)); }, /** * Saves an unique XML view. * * @private * @param {Object} session - the 'id' and the 'text' of the view to save. - * @returns {Promise} status indicates if the save is finished or if an + * @returns {Deferred} status indicates if the save is finished or if an * error occured. */ _saveView: function (session) { + var def = $.Deferred(); + var self = this; - return new Promise(function (resolve, reject) { - self._rpc({ - model: 'ir.ui.view', - method: 'write', - args: [[session.id], {arch: session.text}], - }, { - noContextKeys: 'lang', - }).then(function () { - self._toggleDirtyInfo(session.id, 'xml', false); - resolve(); - }, function (source, error) { - reject(session, error); - }); + this._rpc({ + model: 'ir.ui.view', + method: 'write', + args: [[session.id], {arch: session.text}], + }, { + noContextKeys: 'lang', + }).then(function () { + self._toggleDirtyInfo(session.id, 'xml', false); + def.resolve(); + }, function (source, error) { + def.reject(session, error); }); + + return def; }, /** * Shows a line which produced an error. Red color is added to the editor, @@ -704,11 +664,11 @@ var ViewEditor = Widget.extend({ } }, /** - * Switches to the SCSS, XML or JS edition. Calling this method will adapt all + * Switches to the SCSS or XML edition. Calling this method will adapt all * DOM elements to keep the editor consistent. * * @private - * @param {string} type - either 'xml', 'scss' or 'js' + * @param {string} type - either 'xml' or 'scss' */ _switchType: function (type) { this.currentType = type; @@ -716,10 +676,9 @@ var ViewEditor = Widget.extend({ _.each(this.$lists, function ($list, _type) { $list.toggleClass('d-none', type !== _type); }); this.$lists[type].change(); - this.$includeBundlesArea.toggleClass('d-none', this.currentType !== 'xml' || !config.isDebug()); - this.$includeAllSCSSArea.toggleClass('d-none', this.currentType !== 'scss' || !config.isDebug()); - this.$includeAllSCSSArea.find('[data-value="restricted"]').toggleClass('d-none', this.options.defaultBundlesRestriction.length === 0); - this.$formatButton.toggleClass('d-none', this.currentType !== 'xml'); + this.$includeBundlesArea.toggleClass('d-none', this.currentType === 'scss' || !session.debug); + this.$includeAllSCSSArea.toggleClass('d-none', this.currentType === 'xml' || !session.debug || this.options.defaultBundlesRestriction.length === 0); + this.$formatButton.toggleClass('d-none', this.currentType === 'scss'); }, /** * Updates the select option DOM element associated with a particular resID @@ -764,10 +723,21 @@ var ViewEditor = Widget.extend({ }); this.$lists.scss.empty(); - _populateList(this.sortedSCSS, this.$lists.scss, 5); - - this.$lists.js.empty(); - _populateList(this.sortedJS, this.$lists.js, 3); + _.each(this.sortedSCSS, function (bundleInfos) { + var $optgroup = $('<optgroup/>', { + label: bundleInfos[0].name, + }).appendTo(self.$lists.scss); + _.each(bundleInfos[1], function (scssInfo) { + var name = scssInfo.url.substring(_.lastIndexOf(scssInfo.url, '/') + 1, scssInfo.url.length - 5); + $optgroup.append($('<option/>', { + value: scssInfo.url, + text: name, + selected: currentId === scssInfo.url, + 'data-debug': scssInfo.url, + 'data-customized': scssInfo.customized + })); + }); + }); this.$lists.xml.select2('destroy'); this.$lists.xml.select2({ @@ -781,30 +751,6 @@ var ViewEditor = Widget.extend({ formatSelection: _formatDisplay.bind(this, true), }); this.$lists.scss.data('select2').dropdown.addClass('o_ace_select2_dropdown'); - this.$lists.js.select2('destroy'); - this.$lists.js.select2({ - formatResult: _formatDisplay.bind(this, false), - formatSelection: _formatDisplay.bind(this, true), - }); - this.$lists.js.data('select2').dropdown.addClass('o_ace_select2_dropdown'); - - function _populateList(sortedData, $list, lettersToRemove) { - _.each(sortedData, function (bundleInfos) { - var $optgroup = $('<optgroup/>', { - label: bundleInfos[0].name, - }).appendTo($list); - _.each(bundleInfos[1], function (dataInfo) { - var name = dataInfo.url.substring(_.lastIndexOf(dataInfo.url, '/') + 1, dataInfo.url.length - lettersToRemove); - $optgroup.append($('<option/>', { - value: dataInfo.url, - text: name, - selected: currentId === dataInfo.url, - 'data-debug': dataInfo.url, - 'data-customized': dataInfo.customized - })); - }); - }); - } function _formatDisplay(isSelected, data) { var $elem = $(data.element); @@ -824,7 +770,7 @@ var ViewEditor = Widget.extend({ })); } - if (!isSelected && config.isDebug() && $elem.data('debug')) { + if (!isSelected && session.debug && $elem.data('debug')) { $div.append($('<span/>', { text: ' (' + $elem.data('debug') + ')', class: 'ml4 small text-muted', @@ -856,20 +802,25 @@ var ViewEditor = Widget.extend({ this._formatResource(); }, /** - * Called when a filter dropdown item is cliked. Reload the resources - * according to the new filter and make it visually active. + * Called when the checkbox which indicates if all SCSS should be included + * is toggled -> reloads the resources accordingly. * * @private * @param {Event} ev */ - _onFilterChange: function (ev) { - var $item = $(ev.target); - $item.addClass('active').siblings().removeClass('active'); - if ($item.data('type') === 'xml') { - this.options.includeBundles = $(ev.target).data('value') === 'all'; - } else { - this.options.filesFilter = $item.data('value'); - } + _onIncludeAllSCSSChange: function (ev) { + this.options.includeAllSCSS = $(ev.target).prop('checked'); + this._loadResources().then(this._updateViewSelectDOM.bind(this)); + }, + /** + * Called when the checkbox which indicates if assets bundles should be + * included is toggled -> reloads the resources accordingly. + * + * @private + * @param {Event} ev + */ + _onIncludeBundlesChange: function (ev) { + this.options.includeBundles = $(ev.target).prop('checked'); this._loadResources().then(this._updateViewSelectDOM.bind(this)); }, /** @@ -915,13 +866,6 @@ var ViewEditor = Widget.extend({ ev.preventDefault(); this._switchType($(ev.target).data('type')); }, - /** - * Allows to hide the warning message without removing it from the DOM - * -> by default Bootstrap removes alert from the DOM - */ - _onCloseWarningClick: function () { - this.$warningMessage.addClass('d-none'); - }, }); return ViewEditor; diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js new file mode 100644 index 00000000000..63fa8c1c667 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/editor.js @@ -0,0 +1,286 @@ +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 new file mode 100644 index 00000000000..ba6e1fed7ec --- /dev/null +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -0,0 +1,749 @@ +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 new file mode 100644 index 00000000000..464c58ed4ee --- /dev/null +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -0,0 +1,1277 @@ +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 () { + // Do not show tooltip on double-click and if there is already one + if (!show_tooltip || $target.attr('title') !== undefined) { + 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/wysiwyg_snippets/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index 4193aef5332..776d9992bb7 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -5,7 +5,6 @@ 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; @@ -25,7 +24,6 @@ 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', @@ -37,15 +35,9 @@ var SnippetEditor = Widget.extend({ * @param {Widget} parent * @param {Element} target * @param templateOptions - * @param {jQuery} $editable - * @param {Object} options */ - init: function (parent, target, templateOptions, $editable, options) { + init: function (parent, target, templateOptions) { 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; @@ -67,13 +59,13 @@ var SnippetEditor = Widget.extend({ defs.push(this._initializeOptions()); // Initialize move/clone/remove buttons - if (!this.options.isEditableNode(this.$target[0])) { + if (!this.$target.parent().is(':o_editable')) { this.$el.find('.oe_snippet_move, .oe_snippet_clone, .oe_snippet_remove').remove(); } else { this.dropped = false; this.$el.draggable({ greedy: true, - appendTo: this.$body, + appendTo: 'body', cursor: 'move', handle: '.oe_snippet_move', cursorAt: { @@ -84,7 +76,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(self.$body).removeClass('d-none'); + $clone.appendTo('body').removeClass('d-none'); return $clone; }, start: _.bind(self._onDragAndDropStart, self), @@ -102,7 +94,7 @@ var SnippetEditor = Widget.extend({ } }); - return Promise.all(defs); + return $.when.apply($, defs); }, /** * @override @@ -158,7 +150,7 @@ var SnippetEditor = Widget.extend({ top: offset.top, }); this.$('.o_handles').css('height', this.$target.outerHeight()); - this.$el.toggleClass('o_top_cover', offset.top < this.$editable.offset().top); + this.$el.toggleClass('o_top_cover', offset.top < 15); }, /** * Removes the associated snippet from the DOM and destroys the associated @@ -177,12 +169,13 @@ var SnippetEditor = Widget.extend({ }); var $parent = this.$target.parent(); - this.$target.find('*').addBack().tooltip('dispose'); + this.$target.find('*').andSelf().tooltip('dispose'); this.$target.remove(); this.$el.remove(); var node = $parent[0]; if (node && node.firstChild) { + $.summernote.core.dom.removeSpace(node, node.firstChild, 0, node.lastChild, 1); if (!node.firstChild.tagName && node.firstChild.textContent === ' ') { node.removeChild(node.firstChild); } @@ -204,12 +197,11 @@ var SnippetEditor = Widget.extend({ } // clean editor if they are image or table in deleted content - this.$body.find('.note-control-selection').hide(); - this.$body.find('.o_table_handler').remove(); + $('.note-control-selection').hide(); + $('.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 @@ -219,9 +211,6 @@ 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 @@ -249,10 +238,6 @@ 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(); @@ -313,8 +298,7 @@ var SnippetEditor = Widget.extend({ self, val.base_target ? self.$target.find(val.base_target).eq(0) : self.$target, self.$el, - val.data, - self.options + val.data ); self.styles[optionName || _.uniqueId('option')] = option; option.__order = i++; @@ -341,7 +325,7 @@ var SnippetEditor = Widget.extend({ this.$el.find('[data-toggle="dropdown"]').dropdown(); - return Promise.all(defs); + return $.when.apply($, defs); }, //-------------------------------------------------------------------------- @@ -356,13 +340,13 @@ var SnippetEditor = Widget.extend({ */ _onCloneClick: function (ev) { ev.preventDefault(); - this.trigger_up('cover_will_change'); this.trigger_up('snippet_will_be_cloned', {$target: this.$target}); 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, @@ -375,7 +359,6 @@ var SnippetEditor = Widget.extend({ }, }); this.trigger_up('snippet_cloned', {$target: $clone, $origin: this.$target}); - $clone.trigger('content_changed'); }, /** * Called when the overlay dimensions/positions should be recomputed. @@ -383,7 +366,6 @@ var SnippetEditor = Widget.extend({ * @private */ _onCoverUpdate: function () { - this.trigger_up('cover_will_change'); this.cover(); }, /** @@ -420,11 +402,11 @@ var SnippetEditor = Widget.extend({ $selectorChildren: $selectorChildren, }); - this.$body.addClass('move-important'); + $('body').addClass('move-important'); - this.$editable.find('.oe_drop_zone').droppable({ + $('.oe_drop_zone').droppable({ over: function () { - self.$editable.find('.oe_drop_zone.hide').removeClass('hide'); + $('.oe_drop_zone.hide').removeClass('hide'); $(this).addClass('hide').first().after(self.$target); self.dropped = true; }, @@ -434,8 +416,6 @@ 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 @@ -446,7 +426,7 @@ var SnippetEditor = Widget.extend({ * @param {Object} ui */ _onDragAndDropStop: function (ev, ui) { - this.$editable.find('.oe_drop_zone').droppable('destroy').remove(); + var self = this; // TODO lot of this is duplicated code of the d&d feature of snippets if (!this.dropped) { @@ -457,21 +437,22 @@ var SnippetEditor = Widget.extend({ } } + $('.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 = this.$editable.find('.oe_drop_clone'); + var $clone = $('.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'); - this.$body.removeClass('move-important'); + $('body').removeClass('move-important'); $clone.remove(); if (this.dropped) { @@ -488,13 +469,10 @@ var SnippetEditor = Widget.extend({ for (var i in this.styles) { this.styles[i].onMove(); } - - this.$target.trigger('content_changed'); - $from.trigger('content_changed'); } - this.trigger_up('drag_and_drop_stop', { - $snippet: this.$target, + self.trigger_up('drag_and_drop_stop', { + $snippet: self.$target, }); }, /** @@ -505,7 +483,6 @@ 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) { @@ -529,16 +506,6 @@ 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 @@ -558,7 +525,6 @@ 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(); }, @@ -569,7 +535,6 @@ var SnippetEditor = Widget.extend({ */ var SnippetsMenu = Widget.extend({ id: 'oe_snippets', - cacheSnippetTemplate: {}, activeSnippets: [], custom_events: { activate_insertion_zones: '_onActivateInsertionZones', @@ -579,34 +544,15 @@ 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, options) { + init: function (parent, $editable) { this._super.apply(this, arguments); - options = options || {}; - this.trigger_up('getRecordInfo', { - recordInfo: options, - callback: function (recordInfo) { - _.defaults(options, recordInfo); - }, - }); - this.options = options; - if (!this.options.snippets) { - this.options.snippets = 'web_editor.snippets'; - } + this.$editable = $editable; this.$activeSnippet = false; this.snippetEditors = []; }, @@ -616,14 +562,12 @@ var SnippetsMenu = Widget.extend({ start: function () { var self = this; var defs = [this._super.apply(this, arguments)]; - this.ownerDocument = this.$el[0].ownerDocument; - this.$document = $(this.ownerDocument); - this.window = this.ownerDocument.defaultView; - this.$window = $(this.window); + var $document = $(document); + var $window = $(window); // Fetch snippet templates and compute it - - defs.push(this.loadSnippets().then(function (html) { + var url = this._getSnippetURL(); + defs.push(this._rpc({route: url}).then(function (html) { return self._computeSnippetTemplates(html); })); @@ -634,8 +578,8 @@ var SnippetsMenu = Widget.extend({ // Active snippet editor on click in the page var lastClickedElement; - this.$document.on('click.snippets_menu', '*', function (ev) { - var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement; + $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; } @@ -654,21 +598,27 @@ 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); - this.$window.on('resize.snippets_menu', debouncedCoverUpdate); - this.$window.on('content_changed.snippets_menu', debouncedCoverUpdate); + $window.on('resize.snippets_menu', debouncedCoverUpdate); + $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 - this.$document.on('keydown.snippets_menu', function () { + $document.on('keydown.snippets_menu', function () { if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) { self.$activeSnippet.data('snippet-editor').$el.addClass('o_keypress'); } }); - this.$document.on('mousemove.snippets_menu, mousedown.snippets_menu', function () { + $document.on('mousemove.snippets_menu', function () { if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) { self.$activeSnippet.data('snippet-editor').$el.removeClass('o_keypress'); } @@ -676,21 +626,20 @@ var SnippetsMenu = Widget.extend({ // Auto-selects text elements with a specific class and remove this // on text changes - this.$document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) { + $document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) { $(ev.target).selectContent(); - $(ev.target).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'); + $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'); }); - return Promise.all(defs).then(function () { + return $.when.apply($, defs).then(function () { // Trigger a resize event once entering edit mode as the snippets // menu will take part of the screen width (delayed because of // animation). (TODO wait for real animation end) setTimeout(function () { - self.$window.trigger('resize'); + $window.trigger('resize'); }, 1000); }); }, @@ -699,14 +648,11 @@ var SnippetsMenu = Widget.extend({ */ destroy: function () { this._super.apply(this, arguments); - if (this.$window) { - this.$snippetEditorArea.remove(); - this.$window.off('.snippets_menu'); - this.$document.off('.snippets_menu'); - } + this.$snippetEditorArea.remove(); + $(window).off('.snippets_menu'); + $(document).off('.snippets_menu'); core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet); core.bus.off('snippet_editor_clean_for_save', this, this._onCleanForSaveDemand); - delete this.cacheSnippetTemplate[this.options.snippets]; }, //-------------------------------------------------------------------------- @@ -719,69 +665,16 @@ var SnippetsMenu = Widget.extend({ * - Remove the 'contentEditable' attributes */ cleanForSave: function () { - this._activateSnippet(false); this.trigger_up('ready_to_clean_for_save'); this._destroyEditors(); - this.getEditableArea().find('[contentEditable]') + this.$editable.find('[contentEditable]') .removeAttr('contentEditable') .removeProp('contentEditable'); - this.getEditableArea().find('.o_we_selected_image') + this.$editable.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; - return this.cacheSnippetTemplate[this.options.snippets]; - }, - /** - * 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 @@ -799,7 +692,6 @@ var SnippetsMenu = Widget.extend({ * child */ _activateInsertionZones: function ($selectorSiblings, $selectorChildren) { - var self = this; var zone_template = $('<div/>', { class: 'oe_drop_zone oe_insert', }); @@ -811,8 +703,8 @@ var SnippetsMenu = Widget.extend({ if ($selectorChildren) { $selectorChildren.each(function () { var $zone = $(this); - var css = self.window.getComputedStyle(this); - var parentCss = self.window.getComputedStyle($zone.parent()[0]); + var css = window.getComputedStyle(this); + var parentCss = window.getComputedStyle($zone.parent()[0]); var float = css.float || css.cssFloat; var parentDisplay = parentCss.display; var parentFlex = parentCss.flexDirection; @@ -823,7 +715,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(self.window.getComputedStyle($zone[0]).lineHeight), + height: parseInt(window.getComputedStyle($zone[0]).lineHeight), float: 'none', display: 'inline-block', }); @@ -841,7 +733,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(self.window.getComputedStyle($zone[0]).lineHeight), + height: parseInt(window.getComputedStyle($zone[0]).lineHeight), float: 'none', display: 'inline-block' }); @@ -864,8 +756,8 @@ var SnippetsMenu = Widget.extend({ $selectorSiblings.filter(':not(.oe_drop_zone):not(.oe_drop_clone)').each(function () { var $zone = $(this); var $drop; - var css = self.window.getComputedStyle(this); - var parentCss = self.window.getComputedStyle($zone.parent()[0]); + var css = window.getComputedStyle(this); + var parentCss = window.getComputedStyle($zone.parent()[0]); var float = css.float || css.cssFloat; var parentDisplay = parentCss.display; var parentFlex = parentCss.flexDirection; @@ -897,14 +789,14 @@ var SnippetsMenu = Widget.extend({ var $zones; do { count = 0; - $zones = this.getEditableArea().find('.oe_drop_zone > .oe_drop_zone').remove(); // no recursive zones + $zones = this.$editable.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.getEditableArea().find('.oe_drop_zone:not(.oe_vertical)'); + $zones = this.$editable.find('.oe_drop_zone:not(.oe_vertical)'); $zones.each(function () { var zone = $(this); var prev = zone.prev(); @@ -938,8 +830,7 @@ var SnippetsMenu = Widget.extend({ * @param {jQuery|false} $snippet * The DOM element whose editor need to be enabled. Only disable the * current one if false is given. - * @returns {Promise<SnippetEditor>} - * (might be async when an editor must be created) + * @returns {Deferred} (might be async when an editor must be created) */ _activateSnippet: function ($snippet) { if ($snippet) { @@ -947,27 +838,25 @@ var SnippetsMenu = Widget.extend({ $snippet = globalSelector.closest($snippet); } if (this.$activeSnippet && this.$activeSnippet[0] === $snippet[0]) { - return Promise.resolve($snippet.data('snippet-editor')); + return $.when(); } } - var editor = null; if (this.$activeSnippet) { - editor = this.$activeSnippet.data('snippet-editor'); - if (editor) { - editor.toggleFocus(false); + if (this.$activeSnippet.data('snippet-editor')) { + this.$activeSnippet.data('snippet-editor').toggleFocus(false); } this.$activeSnippet = false; } if ($snippet && $snippet.length) { var self = this; - this.trigger_up('activate_snippet', $snippet); - return this._createSnippetEditor($snippet).then(function (editor) { + return this._createSnippetEditor($snippet).then(function () { self.$activeSnippet = $snippet; - editor.toggleFocus(true); - return editor; + if (self.$activeSnippet.data('snippet-editor')) { + self.$activeSnippet.data('snippet-editor').toggleFocus(true); + } }); } - return Promise.resolve(editor); + return $.when(); }, /** * @private @@ -978,6 +867,16 @@ 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). * @@ -988,7 +887,7 @@ var SnippetsMenu = Widget.extend({ * @param {function} callback * Given two arguments: the snippet editor associated to the snippet * being managed and the DOM element of this snippet. - * @returns {Promise} (might be async if snippet editors need to be created + * @returns {Deferred} (might be async if snippet editors need to be created * and/or the callback is async) */ _callForEachChildSnippet: function ($snippet, callback) { @@ -1001,7 +900,7 @@ var SnippetsMenu = Widget.extend({ } }); }); - return Promise.all(defs); + return $.when.apply($, defs); }, /** * Creates and returns a set of helper functions which can help finding @@ -1056,7 +955,7 @@ var SnippetsMenu = Widget.extend({ selectorConditions += ':has(' + target + ')'; } if (!noCheck) { - selectorConditions = (this.options.addDropSelector || '') + selectorConditions; + selectorConditions = ':o_editable' + selectorConditions; } // (Re)join the subselectors @@ -1079,7 +978,7 @@ var SnippetsMenu = Widget.extend({ }; } else { functions.closest = function ($from, parentNode) { - var parents = self.getEditableArea().get(); + var parents = self.$editable.get(); return $from.closest(selector, parentNode).filter(function () { var node = this; while (node.parentNode) { @@ -1092,9 +991,9 @@ var SnippetsMenu = Widget.extend({ }); }; functions.all = isChildren ? function ($from) { - return dom.cssFind($from || self.getEditableArea(), selector); + return dom.cssFind($from || self.$editable, selector); } : function ($from) { - $from = $from || self.getEditableArea(); + $from = $from || self.$editable; return $from.filter(selector).add(dom.cssFind($from, selector)); }; } @@ -1221,6 +1120,7 @@ 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(); @@ -1252,13 +1152,13 @@ var SnippetsMenu = Widget.extend({ * * @private * @param {jQuery} $snippet - * @returns {Promise<SnippetEditor>} + * @returns {Deferred<SnippetEditor>} */ _createSnippetEditor: function ($snippet) { var self = this; var snippetEditor = $snippet.data('snippet-editor'); if (snippetEditor) { - return Promise.resolve(snippetEditor); + return $.when(snippetEditor); } var def; @@ -1267,8 +1167,8 @@ var SnippetsMenu = Widget.extend({ def = this._createSnippetEditor($parent); } - return Promise.resolve(def).then(function (parentEditor) { - snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions, self.getEditableArea(), self.options); + return $.when(def).then(function (parentEditor) { + snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions); self.snippetEditors.push(snippetEditor); return snippetEditor.appendTo(self.$snippetEditorArea); }).then(function () { @@ -1304,6 +1204,17 @@ 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 @@ -1319,7 +1230,8 @@ var SnippetsMenu = Widget.extend({ $snippets.draggable({ greedy: true, helper: 'clone', - appendTo: this.$body, + zIndex: '1000', + appendTo: 'body', cursor: 'move', handle: '.oe_snippet_thumbnail', distance: 30, @@ -1337,10 +1249,12 @@ 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']) { - $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all()); + if (!$selectorSiblings) $selectorSiblings = temp[k]['drop-near'].all(); + else $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all()); } if (temp[k]['drop-in']) { - $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all()); + if (!$selectorChildren) $selectorChildren = temp[k]['drop-in'].all(); + else $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all()); } } } @@ -1355,12 +1269,11 @@ var SnippetsMenu = Widget.extend({ self._activateSnippet(false); self._activateInsertionZones($selectorSiblings, $selectorChildren); - self.getEditableArea().find('.oe_drop_zone').droppable({ + $('.oe_drop_zone').droppable({ over: function () { if (!dropped) { dropped = true; $(this).first().after($toInsert).addClass('d-none'); - $toInsert.removeClass('oe_snippet_body'); } }, out: function () { @@ -1369,25 +1282,22 @@ var SnippetsMenu = Widget.extend({ dropped = false; $toInsert.detach(); $(this).removeClass('d-none'); - $toInsert.addClass('oe_snippet_body'); } }, }); - - self.trigger_up('cover_will_change'); }, stop: function (ev, ui) { $toInsert.removeClass('oe_snippet_body'); if (!dropped && ui.position.top > 3 && ui.position.left + 50 > self.$el.outerWidth()) { - var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone', {container: document.body}).first(); + var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone').first(); if ($el.length) { $el.after($toInsert); dropped = true; } } - self.getEditableArea().find('.oe_drop_zone').droppable('destroy').remove(); + self.$editable.find('.oe_drop_zone').droppable('destroy').remove(); if (dropped) { var prev = $toInsert.first()[0].previousSibling; @@ -1408,6 +1318,8 @@ var SnippetsMenu = Widget.extend({ $parent.prepend($toInsert); } + $toInsert.closest('.o_editable').trigger('content_changed'); + var $target = $toInsert; _.defer(function () { @@ -1415,12 +1327,12 @@ var SnippetsMenu = Widget.extend({ self._disableUndroppableSnippets(); self._callForEachChildSnippet($target, function (editor, $snippet) { - editor.buildSnippet(); + _.defer(function () { + editor.buildSnippet(); + }); }).then(function () { - $target.trigger('content_changed'); - if ($target.closest('body').length) { // can be destroyed (eg in test) - self._activateSnippet($target); - } + $target.closest('.o_editable').trigger('content_changed'); + self._activateSnippet($target); }); }); } else { diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 98180ea4fcb..f481061c9e2 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -4,9 +4,8 @@ odoo.define('web_editor.snippets.options', function (require) { var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); -var weWidgets = require('wysiwyg.widgets'); -var FontPlugin = require('web_editor.wysiwyg.plugin.font'); -var ColorpickerDialog = require('web.ColorpickerDialog'); +var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); +var weWidgets = require('web_editor.widget'); var qweb = core.qweb; var _t = core._t; @@ -19,12 +18,10 @@ var _t = core._t; var SnippetOption = Widget.extend({ events: { 'mouseenter': '_onLinkEnter', - 'mouseenter a': '_onLinkEnter', + 'mouseenter .dropdown-item': '_onLinkEnter', 'click': '_onLinkClick', - 'click a': '_onLinkClick', 'mouseleave': '_onMouseleave', - 'mouseleave a': '_onMouseleave', - 'mouseleave .dropdown-menu': '_onMouseleave', + 'mouseleave .dropdown-item': '_onMouseleave', }, /** * When editing a snippet, its options are shown alongside the ones of its @@ -41,11 +38,9 @@ var SnippetOption = Widget.extend({ * * @constructor */ - init: function (parent, $target, $overlay, data, options) { + init: function (parent, $target, $overlay, data) { 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 = []; @@ -245,7 +240,6 @@ 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 @@ -373,7 +367,7 @@ var SnippetOption = Widget.extend({ */ _onLinkClick: function (ev) { var $opt = $(ev.target).closest('.dropdown-item'); - if (ev.isDefaultPrevented() || !$opt.length || !$opt.is(':hasData')) { + if (!$opt.length || !$opt.is(':hasData')) { return; } @@ -460,7 +454,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 = $(this.ownerDocument.body); + var $body = $(document.body); $body.addClass(cursor); var xy = ev['page' + XY]; @@ -667,19 +661,12 @@ registry.colorpicker = SnippetOption.extend({ } if (!this.$el.find('.colorpicker').length) { - // Add common colors to palettes if not excluded - var fontPlugin = new FontPlugin({ - layoutInfo: { - editable: $('<div/>'), - editingArea: $('<div/>'), - }, - options: {}, - }); + // TODO remove old UI's code that survived + var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); + var $clpicker = $(qweb.render('web_editor.colorpicker')); - 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())); + _.each($clpicker.find('.o_colorpicker_section'), function (elem) { + $(elem).prepend("<div class='text-muted mt8'>" + elem.dataset.display + "</div>"); }); // Retrieve excluded palettes list @@ -689,7 +676,7 @@ registry.colorpicker = SnippetOption.extend({ } // Apply a custom title if specified if (this.data.paletteTitle) { - $clpicker.find('.note-palette-title').text(this.data.paletteTitle); + $pt.find('.note-palette-title').text(this.data.paletteTitle); } // Remove excluded palettes @@ -697,29 +684,39 @@ registry.colorpicker = SnippetOption.extend({ $clpicker.find('[data-name="' + exc + '"]').remove(); }); - var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); + // 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 + '" />'); + }); + }); + } + $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) { - $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'); - } + if (!color) { + return; + } + + $color.addClass('bg-' + color); + var className = self.colorPrefix + color; + if (self.$target.hasClass(className)) { + $color.addClass('selected'); } + classes.push(className); }); this.classes = classes.join(' '); @@ -753,9 +750,6 @@ 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) @@ -848,20 +842,18 @@ 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 === 'true' ? '' : value}).appendTo(this.$target); + var $image = $('<img/>', {class: 'd-none', src: value}).appendTo(this.$target); var $editable = this.$target.closest('.o_editable'); var _editor = new weWidgets.MediaDialog(this, { onlyImages: true, - mediaWidth: 1920, firstFilters: ['background'], res_model: $editable.data('oe-model'), res_id: $editable.data('oe-id'), - }, $image[0]).open(); + }, null, $image[0]).open(); - _editor.on('save', this, function (image) { - this._setCustomBackground(image.src); - this.$target.trigger('content_changed'); + _editor.on('save', this, function () { + this._setCustomBackground($image.attr('src')); }); _editor.on('closed', this, function () { $image.remove(); @@ -916,8 +908,7 @@ registry.background = SnippetOption.extend({ if (value === undefined) { value = this.$target.css('background-image'); } - var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g; - return value && value.replace(srcValueWrapper, '') || ''; + return value.replace(/url\(['"]*|['"]*\)|^none$/g, ''); }, /** * @override @@ -1182,11 +1173,6 @@ 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'); @@ -1219,7 +1205,6 @@ registry.many2one = SnippetOption.extend({ this.$search.find('input') .focus() .on('keyup', function (e) { - self.$overlay.removeClass('o_keypress'); self._findExisting($(this).val()); }); @@ -1260,7 +1245,7 @@ registry.many2one = SnippetOption.extend({ * * @private * @param {string} name - * @returns {Promise} + * @returns {Deferred} */ _findExisting: function (name) { var self = this; @@ -1286,7 +1271,6 @@ registry.many2one = SnippetOption.extend({ kwargs: { order: [{name: 'name', asc: false}], limit: 5, - context: this.options.context, }, }).then(function (result) { self.$search.siblings().remove(); @@ -1325,7 +1309,6 @@ 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/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js new file mode 100644 index 00000000000..e0968ecdb58 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -0,0 +1,2452 @@ +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]; + + // set selection outside of A if range is at beginning or end + var elem = dom.isBR(elem) ? elem.parentNode : dom.node(r.sc); + if (elem.tagName === "A") { + if (r.so === 0 && dom.firstChild(elem) === r.sc) { + r.ec = r.sc = dom.hasContentBefore(elem) || $(dom.createText('')).insertBefore(elem)[0]; + r.eo = r.so = dom.nodeLength(r.sc); + r.select(); + } else if (dom.nodeLength(r.sc) === r.so && dom.lastChild(elem) === r.sc) { + r.ec = r.sc = dom.hasContentAfter(elem) || dom.insertAfter(dom.createText(''), elem); + r.eo = r.so = 0; + r.select(); + } + } + + 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) { + // dom.node chooses the parent if node is text + var cur = dom.node(dom.lastChild(node.previousSibling)); + if (!dom.isBR(cur)) { + // We should concat what was before with a <br> + $(cur).html(cur.innerHTML + br.outerHTML); + } + } + 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) { + sLinkUrl = sLinkUrl.replace(/^tel:([0-9]+)$/, 'tel://$1'); + } 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/backend/convert_inline.js b/addons/web_editor/static/src/js/editor/transcoder.js index b5c69102ff6..745e8492b11 100644 --- a/addons/web_editor/static/src/js/backend/convert_inline.js +++ b/addons/web_editor/static/src/js/editor/transcoder.js @@ -1,7 +1,9 @@ -odoo.define('web_editor.convertInline', function (require) { +odoo.define('web_editor.transcoder', function (require) { 'use strict'; -var FieldHtml = require('web_editor.field.html'); +var base = require('web_editor.base'); + +var rulesCache = []; /** * Returns the css rules which applies on an element, tweaked so that they are @@ -12,11 +14,8 @@ var FieldHtml = require('web_editor.field.html'); */ function getMatchedCSSRules(a) { var i, r, k; - var doc = a.ownerDocument; - var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []); - if (!rulesCache.length) { - var sheets = doc.styleSheets; + var sheets = document.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 @@ -188,13 +187,11 @@ function getMatchedCSSRules(a) { * converted to images */ function fontToImg($editable) { - var fonts = odoo.__DEBUG__.services["wysiwyg.fonts"]; - $editable.find('.fa').each(function () { var $font = $(this); var icon, content; - _.find(fonts.fontIcons, function (font) { - return _.find(fonts.getCssSelectors(font.parser), function (data) { + _.find(base.fontIcons, function (font) { + return _.find(base.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]; @@ -268,6 +265,9 @@ 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,7 +328,9 @@ function styleToClass($editable) { $(this).after($('a,img', this)); }).remove(); - var $c = $('<span/>').appendTo($editable[0].ownerDocument.body); + getMatchedCSSRules($editable[0]); + + var $c = $('<span/>').appendTo(document.body); applyOverDescendants($editable[0], function (node) { var $target = $(node); @@ -340,7 +342,7 @@ function styleToClass($editable) { } }); css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); - style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';'); + style = $target.attr('style') || ''; _.each(css, function (v) { style = style.replace(v, ''); }); @@ -362,13 +364,13 @@ function styleToClass($editable) { * @param {jQuery} $editable */ function attachmentThumbnailToLinkImg($editable) { - $editable.find('a[href*="/web/content/"][data-mimetype]').filter(':empty, :containsExact( )').each(function () { + $editable.find('a[href*="/web/content/"][data-mimetype]:empty').each(function () { var $link = $(this); var $img = $('<img/>') .attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, '')) .css('height', Math.max(1, $link.height()) + 'px') .css('width', Math.max(1, $link.width()) + 'px'); - $link.prepend($img); + $link.append($img); }); } @@ -382,83 +384,6 @@ function linkImgToAttachmentThumbnail($editable) { $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove(); } - -//-------------------------------------------------------------------------- -//-------------------------------------------------------------------------- - - -FieldHtml.include({ - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - commitChanges: function () { - if (this.nodeOptions['style-inline'] && this.isRendered) { - 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/tours/rte.js b/addons/web_editor/static/src/js/tours/rte.js new file mode 100644 index 00000000000..abdcd39f35b --- /dev/null +++ b/addons/web_editor/static/src/js/tours/rte.js @@ -0,0 +1,325 @@ +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/wysiwyg/options.js b/addons/web_editor/static/src/js/wysiwyg/options.js deleted file mode 100644 index aeaf8422b72..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/options.js +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index de776020a42..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index 1d5c2d47a33..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js +++ /dev/null @@ -1,571 +0,0 @@ -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 = ul ? [].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; - $(ol).removeClass('o_checklist'); - if (sorted === 'checklist' && current === "ul") { - ul = ol; - } else if (sorted === 'ul' && current === 'checklist') { - ul = ol; - } else { - ul = this.document.createElement(sorted === "ol" ? "ol" : "ul"); - ul.className = ol.className; - $(ul).removeClass('o_checklist'); - $(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; - } - // list groups shouldn't be outdented like regular lists - if ($(dom.ancestor(range.sc, dom.isList)).hasClass('list-group')) { - return; - } - - var self = this; - var nodes = []; - var isWithinElem; - var ancestor = range.commonAncestor() || this.editable; - 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 - var nodes = this.context.invoke('HelperPlugin.getSelectedNodes'); - $dom = $(this._filterEditableFormatNodes(nodes)); - } - } - - // 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 deleted file mode 100644 index 7059c448d81..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 01ffc52b205..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index a88fc50fc7f..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js +++ /dev/null @@ -1,229 +0,0 @@ -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 utils = require('web.utils'); - -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('dragenter', this._onDragenter.bind(this)); - 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.childNodes[range.so]).before(spinner); - } - } else { - range.sc.splitText(range.so); - $(range.sc).after(spinner); - } - spinners.push(spinner); - - // save images as attachments - var def = new Promise(function (resolve) { - // Get image's Base64 string - utils.getDataURLFromFile(file).then(function (result) { - self._uploadImage(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); - resolve(image); - $(image).trigger('dropped'); - }); - }); - }); - defs.push(def); - }); - - this.trigger_up('drop_images', { - spinners: spinners, - promises: defs, - }); - - Promise.all(defs).then(function () { - var defs = []; - $(images).each(function () { - if (!this.height) { - var def = new Promise(function (resolve) { - $(this).one('load error abort', resolve); - }); - defs.push(def); - } - }); - Promise.all(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/attachment/add_data', - params: { - 'data': imageBase64.split(';base64,').pop(), - 'name': fileName, - 'res_id': options.res_id || 0, - 'res_model': options.res_model, - }, - }); - }, - /** - * @private - * @param {JQueryEvent} e - */ - _onDragenter: function (e) { - var range = this.context.invoke('editor.createRange'); - if (!this.editable.contains(range.sc)) { - var node = this.editable; - if (node && node.firstChild) { - node = node.firstChild; - } - range = this.context.invoke('editor.setRange', node, 0, node, 0); - range.select(); - } - }, - /** - * @private - * @param {JQueryEvent} e - */ - _onDrop: function (e) { - e.preventDefault(); - - this.$editor.removeClass('dragover'); - - 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 deleted file mode 100644 index 350a9158188..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/editor.js +++ /dev/null @@ -1,189 +0,0 @@ -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 deleted file mode 100644 index 0956b031747..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/font.js +++ /dev/null @@ -1,567 +0,0 @@ -odoo.define('web_editor.wysiwyg.plugin.font', function (require) { -'use strict'; - -var core = require('web.core'); -var ColorpickerDialog = require('web.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; - } - - if (wysiwyg.isDestroyed()) { - throw new Error('The Wysiwyg are destroyed before this loading'); - } - - 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) { - if (!/^<templates>/.test(template)) { - template = _.str.sprintf('<templates>%s</templates>', 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 deleted file mode 100644 index f30b16b8bfc..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 2a28cbb221d..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index d65c91aa682..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js +++ /dev/null @@ -1,1948 +0,0 @@ -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); - var nodeAttributes = this._getAttrsNoID(node); - var otherNodeAttributes = this._getAttrsNoID(otherNode); - if (nodeAttributes.length !== otherNodeAttributes.length) { - return false; - } - for (var i = 0; i < nodeAttributes.length; i++) { - var attr = nodeAttributes[i]; - var otherAttr = otherNodeAttributes[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} 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 {number} pointA.offset - * @param {Object} pointB - * @param {Node} pointB.node - * @param {number} 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.makePoint(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 = []; - var offset; - 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; - } - - // handle merge of text with p (useful in case of <li>text</li> with <li><p>text</p></li>) - if (next && dom.isText(node) && this.isNodeBlockType(next)) { - node = [next, next = node][0]; // swap node and next - direction = direction === 'next' ? 'prev' : 'next'; // switch direction - } - if (next && dom.isText(next) && this.isNodeBlockType(node)) { - if (this.isBlankNode(node, true)) { - $(node).empty(); - } - $(node)[direction === 'next' ? 'append' : 'prepend'](next); - offset = direction === 'next' ? 0 : dom.nodeLength(next); - if (dom.isText(next[prevOrNext])) { - // merge text if needed - var sib = next[prevOrNext]; - var firstText = (direction === 'next' ? sib : next).textContent; - var secondText = (direction === 'next' ? next : sib).textContent; - next.textContent = firstText + secondText; - $(sib).remove(); - offset = firstText.length; - } - result = this.makePoint(next, offset); - 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; - 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); - } - - // UL/OL can only have LI, UL or OR as direct children - if (dom.isList(blockToMergeInto) && !dom.isLi(blockToMergeFrom) && !dom.isList(blockToMergeFrom)) { - return; - } - - 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); - }); - var nextElementSibling = direction === 'next' ? 'nextElementSibling' : 'previousElementSibling'; - li = li && dom.isLi(li) ? li : undefined; - if (li) { - if (li[nextElementSibling]) { - node = li; - } else { - node = dom.ancestor(node, function (n) { - return ((n.tagName === 'UL' || n.tagName === 'OL') && n[nextElementSibling] && - (direction === 'next' || dom.isList(n[nextElementSibling]))); - }); - } - } - - if (!node || !node[nextElementSibling]) { - return false; - } - - node = node[nextElementSibling]; - - 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.isList(node) ? undefined : 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[]} path 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 textContent = range.sc.textContent; - 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); - - try { - range.select(); - } catch (e) { - /* Under certain unknown conditions, the range cannot be - applied and triggered an error. In order to allow the user to - keep editing, we need to prevent the dialog from showing, hence - the use of `console.error`. - If this error appears, the carret is moved to the beginning - of the focused node.*/ - console.error(e); - var log = [ - ['textContent', textContent], - ['trimmed', trimmed], - ['cleanContents', cleanContents], - ]; - if (range) { - log.push( - ['start', range.sc.outerHTML || range.sc.textContent, range.so], - ['end', range.ec.outerHTML || range.ec.textContent, range.eo], - ['parent', range.sc.parentNode && range.sc.parentNode.outerHTML]); - } - console.error('Informations for debugging', log); - } - 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 - * @param {Boolean} brIsBlank true to consider nodes with BR element - * as ultimate only child to be blank - * @returns {Boolean} - */ - isBlankNode: function (node, brIsBlank) { - var self = this; - if (brIsBlank && dom.isBR(node)) { - return true; - } - if (dom.isVoid(node) || dom.isIcon(node)) { - return false; - } - if (this.getRegexBlank({ - space: true, - }).test(node[dom.isText(node) ? 'textContent' : 'innerHTML'])) { - return true; - } - var areAllChildrenBlank = _.all(node.childNodes, function (node) { - return self.isBlankNode(node, brIsBlank); - }); - if (node.childNodes.length && areAllChildrenBlank) { - 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, true)) { - 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(); - }, - /** - * Return true if the `container` contains the `contained` and only - * the `contained` (blank text nodes are ignored). - * - * @param {Node} container - * @param {Node} contained - * @returns {Boolean} - */ - onlyContains: function (container, contained) { - var self = this; - if (!$.contains(container, contained)) { - return false; - } - var $contents = $(container).contents(); - var otherContents = $contents.filter(function (index, node) { - if (node === contained || dom.isText(node) && !self.isVisibleText(node)) { - return false; - } - return true; - }); - return !otherContents.length; - }, - /** - * 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 - //-------------------------------------------------------------------------- - - _getAttrsNoID: function (node) { - var nodeAttributes = []; - for (var i = 0; i < node.attributes.length; i++) { - if (node.attributes[i].name !== 'id') { - nodeAttributes.push(node.attributes[i]); - } - } - return nodeAttributes; - }, - _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); - if (invisible.previousSibling && invisible.previousSibling.tagName === "BR") { - invisible.previousSibling.remove(); - } - 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) { - if (dom.ancestor(textNode, dom.isAnchor)) { - return; - } - 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 deleted file mode 100644 index 708ac89333f..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/hint.js +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index addf6383783..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/history.js +++ /dev/null @@ -1,87 +0,0 @@ -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'); - var newRange = this.context.invoke('editor.createRange'); - if (!this.options.isEditableNode(newRange.sc)) { - var firstEditableSel = '[contenteditable="true"]:not(":hidden")'; - var sc = this.$editable.find(firstEditableSel)[0]; - this.context.invoke('editor.setRange', sc, 0, sc, 0).select(); - } - }, - /** - * 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 deleted file mode 100644 index 9ac05220e45..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js +++ /dev/null @@ -1,1237 +0,0 @@ -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 keycode = ({ - '8': 'BACKSPACE', - '9': 'TAB', - '13': 'ENTER', - '46': 'DELETE', -}); - -var isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); -var iPhone = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; - -var KeyboardPlugin = AbstractPlugin.extend({ - events: iPhone ? - { - 'DOMNodeInserted .note-editable': '_removeGarbageSpans', - } : - { - 'summernote.keydown': '_onKeydown', - 'DOMNodeInserted .note-editable': '_removeGarbageSpans', - 'keypress .note-editable': '_onKeyPress', - 'textInput .note-editable': '_onTextInput', - }, - - //-------------------------------------------------------------------------- - // 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 range = this.context.invoke('editor.createRange'); - range = direction === 'prev' && this._replaceEmptyParentWithEmptyP(range); - if (range) { - range = this._afterDeletion(range, direction); - range.select(); - return true; - } - var didDeleteNodes = this.context.invoke('HelperPlugin.deleteSelection'); - range = this.context.invoke('editor.createRange'); - var wasOnStartOfBR = direction === 'prev' && !range.so && range.sc.tagName === 'BR'; - - this._removeNextEmptyUnbreakable(range.sc); - 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 - }); - $(next).removeClass('o_checked'); - 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'); - }); - - // 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); - - // Move carret to the new button - range = this.context.invoke('editor.setRange', next.firstChild, 0); - range.select(); - } 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(); - } - }, - /** - * Remove the first unbreakable ancestor's next sibling if not a block and empty. - * - * @private - * @param {Node} node - */ - _removeNextEmptyUnbreakable: function (node) { - var self = this; - var unbreakable = dom.ancestor(node, this.options.isUnbreakableNode); - if (unbreakable === this.editable) { - return; - } - var nextUnbreakable = unbreakable && unbreakable.nextElementSibling; - var isNextEmpty = nextUnbreakable && dom.isEmpty(nextUnbreakable) && !dom.isVoid(nextUnbreakable); - var isNextContainsOnlyInvisibleText = nextUnbreakable && _.all($(nextUnbreakable).contents(), function (n) { - return dom.isText(n) && !self.context.invoke('HelperPlugin.isVisibleText', n); - }); - if (!dom.isBlock(nextUnbreakable) && (isNextEmpty || isNextContainsOnlyInvisibleText)) { - $(nextUnbreakable).remove(); - } - }, - /** - * If the range's start container is empty and constitutes the only contents of its parent, - * replace it with an empty p, then rerange. - * - * @private - * @param {Object} range - * @returns {Object|undefined} range - */ - _replaceEmptyParentWithEmptyP: function (range) { - var node = range.sc.childElementCount === 1 && range.sc.firstChild.tagName === 'BR' ? range.sc.firstChild : range.sc; - if (node === this.editable || !node.parentNode || node.parentNode === this.editable) { - return; - } - if ( - dom.isEmpty(node) && - this.context.invoke('HelperPlugin.onlyContains', node.parentNode, node) && - ['LI', 'P'].indexOf(node.parentNode.tagName) === -1 - ) { - var emptyP = this.document.createElement('p'); - var br = this.document.createElement('br'); - $(emptyP).append(br); - $(node.parentNode).before(emptyP).remove(); - range.sc = range.ec = br; - range.so = range.eo = 0; - return range.collapse(true); - } - return; - }, - /** - * 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; - }, - /** - * Select all the contents of the current unbreakable ancestor. - */ - _selectAll: function () { - var self = this; - var range = this.context.invoke('editor.createRange'); - var unbreakable = dom.ancestor(range.sc, this.options.isUnbreakableNode); - var $contents = $(unbreakable).contents(); - var startNode = $contents.length ? $contents[0] : unbreakable; - var pointA = this.context.invoke('HelperPlugin.makePoint', startNode, 0); - pointA = dom.nextPointUntil(pointA, function (point) { - return self.context.invoke('HelperPlugin.isVisibleText', point.node); - }) || pointA; - var endNode = $contents.length ? $contents[$contents.length - 1] : unbreakable; - var endOffset = $contents.length ? dom.nodeLength($contents[$contents.length - 1]) : 1; - var pointB = this.context.invoke('HelperPlugin.makePoint', endNode, endOffset); - pointB = dom.prevPointUntil(pointB, function (point) { - return self.context.invoke('HelperPlugin.isVisibleText', point.node); - }) || pointB; - range.sc = pointA.node; - range.so = pointA.offset; - range.ec = pointB.node; - range.eo = pointB.offset; - range.select().normalize(); - }, - /** - * 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) { - if (e.ctrlKey && e.key === 'a') { - e.preventDefault(); - this._selectAll(); - return; - } - if (!keycode[e.keyCode]) { - return; - } - - this.context.invoke('editor.clearTarget'); - this.context.invoke('MediaPlugin.hidePopovers'); - this.context.invoke('editor.beforeCommand'); - var handled = false; - switch (keycode[e.keyCode]) { - case 'BACKSPACE': - handled = this._onBackspace(e); - break; - case 'TAB': - handled = this._onTab(e); - break; - case 'ENTER': - handled = this._onEnter(e); - break; - case 'DELETE': - handled = this._onDelete(e); - break; - } - if (handled) { - e.preventDefault(); - this._preventTextInEditableDiv(); - this.context.invoke('editor.saveRange'); - this.context.invoke('editor.afterCommand'); - } - return handled; - }, - /** - * 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 char input. - * - * @private - * @param {jQueryEvent} e - */ - _onKeyPress: function (e) { - console.log(e.originalEvent); - if (!isSafari || iPhone) { - e.preventDefault(); - this.context.invoke('HelperPlugin.insertTextInline', e.key); - } - }, - /** - * Handle char input. - * - * @private - * @param {jQueryEvent} e - */ - _onTextInput: function (e) { - if (isSafari && !iPhone) { - return; - } - e.originalEvent.preventDefault(); - - var text = e.originalEvent.data; - if (!text) { - return; - } - if (text === 'Spacebar') { - text = ' '; - } - this.context.invoke('HelperPlugin.insertTextInline', text); - }, -}); - -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 deleted file mode 100644 index d8d261df980..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/link.js +++ /dev/null @@ -1,493 +0,0 @@ -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 - //-------------------------------------------------------------------------- - - /** - * @param {Object} layoutInfo - */ - show: function () { - var self = this; - var linkInfo = this.context.invoke('editor.getLinkInfo'); - this.context.invoke('editor.saveRange'); - return this.showLinkDialog(linkInfo) - .then(function (linkInfo) {}) - .guardedCatch(function () {}); - }, - /** - * @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 = new Promise(function (resolve, reject) { - var linkDialog = new LinkDialog(self.options.parent, { - onClose: function () { - setTimeout(function () { - self.context.invoke('editor.focus'); - }); - } - }, - _.omit(linkInfo, 'range') - ); - - linkDialog.on('save', self, self._wrapCommand(function (newLinkInfo) { - var isCollapsed = linkInfo.range.isCollapsed(); - linkInfo.range.select(); - newLinkInfo.range = linkInfo.range; - - 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'); - self.context.invoke('editor.createLink', 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]); - resolve(linkInfo); - })); - linkDialog.on('closed', self, function () { - this.context.invoke('editor.restoreRange'); - this.context.invoke('LinkPopover.update'); - reject(); - }); - linkDialog.open(); - }); - return def; - }, - /** - * 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.ancestor(e.target, dom.isAnchor) && !dom.isMedia(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') && this.context.options.tooltip) { - $target.data('show_tooltip', true); - setTimeout(function () { - $target.tooltip({ - title: _t('Double-click to edit'), - trigger: 'manual', - 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 deleted file mode 100644 index b8b4a7582fe..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js +++ /dev/null @@ -1,1288 +0,0 @@ -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', - }, - - mousePosition: {}, - _modalOpen: false, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Open the image dialog and listen to its saved/closed events. - */ - showImageDialog: function () { - if (this._modalOpen) { - return; - } - this._modalOpen = true; - this.context.invoke('editor.saveRange'); - var media = this.context.invoke('editor.restoreTarget'); - - var $mediaParent = $(media).parent(); - if ($mediaParent.hasClass('media_iframe_video')) { - media = $mediaParent[0]; - $mediaParent = $mediaParent.parent(); - } - - // If the image is for a field the model should handle the resize so we - // use an arbitrary high size of 1920 instead of using the width of the - // the element. - var mediaWidth = $(media).parent().data('oeField') ? 1920 : $(media).width(); - - var mediaDialog = new weWidgets.MediaDialog(this.options.parent, { - mediaWidth: mediaWidth, - onlyImages: $mediaParent.data('oeField') === 'image' || $mediaParent.data('oeType') === 'image', - }, - $(media).clone()[0] - ); - - mediaDialog.on('save', this, function (element) { - this.insertMedia(media, element); - }); - mediaDialog.on('closed', this, function () { - this.context.invoke('editor.restoreRange'); - this._modalOpen = false; - }); - 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 || !dom.isMedia(target)) { - return; - } - if (!this.options.displayPopover(target)) { - if (dom.isImg(target)) { - this.context.invoke('HandlePlugin.update', 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') && this.context.options.tooltip) { - $target.data('show_tooltip', true); - setTimeout(function () { - $target.tooltip({ - title: _t('Double-click to edit'), - trigger: 'manual', - 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 {Node} newMedia contains the media to insert - */ - insertMedia: function (previous, newMedia) { - if (!newMedia) { - return; - } - 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 === newMedia.tagName) { - // Eg: replace an image with an image -> reapply classes removed by `clear` - var reFaIcons = /fa-(?!spin(\s|$))\S+/g; // do not keep fontawesome icons but keep fa-spin - $(newMedia).addClass(previous.className.replace(reFaIcons, '')); - } - - 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 (!this._isFakeNotEditable(newMedia)) { - 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 (!this._isFakeNotEditable(newMedia)) { - 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); - }, - /** - * Return true if the node is a fake not-editable. - * - * @param {Node} node - * @returns {Boolean} - */ - _isFakeNotEditable: function (node) { - var contentEditableAncestor = dom.ancestor(node, function (n) { - return !!n.contentEditable && n.contentEditable !== 'inherit'; - }); - return !!contentEditableAncestor && contentEditableAncestor.contentEditable === 'false'; - }, - /** - * 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 save/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()[0] - ); - cropImageDialog.on('save', this, function (img) { - this.context.invoke('MediaPlugin.insertMedia', media, img); - }); - 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 save/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()[0] - ); - altDialog.on('save', this, this._wrapCommand(function (img) { - $(media).attr('alt', img.alt) - .attr('title', img.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: 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 Promise.all(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 && 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 displayInfo = imageSize.w >= 170 || (imageSize.w >= 120 && imageSize.h >= 58) || (imageSize.w >= 80 && imageSize.h >= 76); - - var sizingText = ''; - if (displayInfo) { - sizingText = imageSize.w + 'x' + imageSize.h; - sizingText += ' (' + this.lang.image.original + ': '; - } else if (src && imageSize.w >= 80 && imageSize.h >= 32) { - displayInfo = true; - sizingText = '('; - } - - if (src) { - var origImageObj = new Image(); - origImageObj.src = src; - sizingText += 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 deleted file mode 100644 index dfd5b109e74..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index b015faee43f..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/table.js +++ /dev/null @@ -1,244 +0,0 @@ -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 deleted file mode 100644 index 2b7d10d02fd..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/text.js +++ /dev/null @@ -1,811 +0,0 @@ -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 = textOnly ? 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 $html = $('<wrapper>' + clipboardData + '</wrapper>').contents(); - var $clipboardData = this._removeIllegalClipboardElements($html); - - 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 { - var offset = range.so; - this.context.invoke('HelperPlugin.insertTextInline', '\u200B'); - range = this.context.invoke('editor.createRange'); - range.so = offset; - range.eo = offset + 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) { - var isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - var iPhone = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; - if (isSafari && !iPhone) { - return; - } - 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 = _.str.trim(clipboardData) - .replace(/</g, '<').replace(/>/g, '>') - .replace(allNewlines, '<br>'); - clipboardData = $('<p>' + clipboardData + '</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 deleted file mode 100644 index bc7a9f7abbf..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index f7fa5b860f2..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/transform.js +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 388a3b2b2c9..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js +++ /dev/null @@ -1,364 +0,0 @@ -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: - * - * .. code-block:: xml - * - <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> - - * .. code-block: none - - 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 deleted file mode 100644 index 65e9440240b..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/plugin_registry.js +++ /dev/null @@ -1,43 +0,0 @@ -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._loadingDef = Promise.resolve(); - }, - start: function (wysiwyg) { - var fn; - var defs = [this._loadingDef]; - while ((fn = this._jobs.shift())) { - defs.push(fn(wysiwyg)); - } - - var xmlPath; - while ((xmlPath = this._xmlDependencies.shift())) { - defs.push(ajax.loadXML(xmlPath, core.qweb)); - } - - this._loadingDef = Promise.all(defs); - return this._loadingDef; - }, - 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 deleted file mode 100644 index e2f31e42293..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/translation.js +++ /dev/null @@ -1,161 +0,0 @@ -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/wysiwyg_iframe.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js deleted file mode 100644 index fa642338399..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js +++ /dev/null @@ -1,234 +0,0 @@ -odoo.define('web_editor.wysiwyg.iframe', function (require) { -'use strict'; - -var Wysiwyg = require('web_editor.wysiwyg'); -var ajax = require('web.ajax'); -var core = require('web.core'); - -var qweb = core.qweb; - -var _fnSummernoteMaster = $.fn.summernote; -var _summernoteMaster = $.summernote; -$.fn.summernote = function () { - var summernote = this[0].ownerDocument.defaultView._fnSummenoteSlave || _fnSummernoteMaster; - return summernote.apply(this, arguments); -}; -window._fnSummernoteMaster = $.fn.summernote; -window._summernoteMaster = _summernoteMaster; - -/** - * 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 = Promise.resolve({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.$iframe.parents().removeClass('o_wysiwyg_no_transform'); - - 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'); - - // Hack to avoid a parent of the fullscreen iframe to have a - // transform (otherwise the position: fixed won't work) - self.$iframe.parents().toggleClass('o_wysiwyg_no_transform'); - }, - 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 () { - var self = this; - 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 = new Promise(function (resolve) { - window.top[self._onUpdateIframeId] = function (_avoidDoubleLoad) { - if (_avoidDoubleLoad !== avoidDoubleLoad) { - console.warn('Wysiwyg iframe double load detected'); - return; - } - delete window.top[self._onUpdateIframeId]; - var $iframeTarget = self.$iframe.contents().find('#iframe_target'); - $iframeTarget.append(self.$target); - resolve(); - }; - }); - this.$iframe.data('loadDef', def); // for unit test - - // 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 iframeContent = qweb.render('wysiwyg.iframeContent', { - asset: asset, - updateIframeId: this._onUpdateIframeId, - avoidDoubleLoad: _avoidDoubleLoad - }); - this.$iframe[0].contentWindow.document - .open("text/html", "replace") - .write(iframeContent); - }.bind(this)); - }.bind(this)); - - this.$iframe.insertAfter(this.$target); - - return def; - }, -}); - -//-------------------------------------------------------------------------- -// Public helper -//-------------------------------------------------------------------------- - -/** - * Get the current range from Summernote. - * - * @param {Node} [DOM] - * @returns {Object} -*/ -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/wysiwyg_snippets/wysiwyg_snippets.js b/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js deleted file mode 100644 index a091a9eed90..00000000000 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js +++ /dev/null @@ -1,291 +0,0 @@ -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', - reload_snippet_dropzones: '_onReloadSnippetDropzones', - }), - - 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 - */ - 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 Promise.all(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._summernote.invoke('toolbar.update', true); - 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'); - }, - /** - * trigger reload_snippet_dropzones on snippets - * - * @private - */ - _onReloadSnippetDropzones: function () { - this.snippets.trigger('reload_snippet_dropzones'); - }, - /** - * 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(); - - try { - range.so; - } catch (e) { - /* Under certain unknown conditions, the range cannot be - applied and triggered an error. In order to allow the user to - keep editing, we need to prevent the dialog from showing, hence - the use of `console.error`. - If this error appears, the carret is moved to the beginning - of the focused node.*/ - console.error(e); - console.error('Informations for debugging', [ - ['elements', this], - ['outerHTML', this[0].outerHTML], - ['textContent', this[0].textContent], - ['html', this.html()], - ['previous selection', this.end().html()], - ]); - } - - 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; - }, -}); - -return Wysiwyg; - -}); |
