diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/static/src/js/common | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web_editor/static/src/js/common')
| -rw-r--r-- | addons/web_editor/static/src/js/common/ace.js | 944 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/common/utils.js | 266 |
2 files changed, 1210 insertions, 0 deletions
diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js new file mode 100644 index 00000000..0c3198de --- /dev/null +++ b/addons/web_editor/static/src/js/common/ace.js @@ -0,0 +1,944 @@ +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 dom = require('web.dom'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var localStorage = require('web.local_storage'); + +var _t = core._t; + +/** + * Formats a content-check result (@see checkXML, checkSCSS). + * + * @param {boolean} isValid + * @param {integer} [errorLine] needed if isValid is false + * @param {string} [errorMessage] needed if isValid is false + * @returns {Object} + */ +function _getCheckReturn(isValid, errorLine, errorMessage) { + return { + isValid: isValid, + error: isValid ? null : { + line: errorLine, + message: errorMessage, + }, + }; +} +/** + * Checks the syntax validity of some XML. + * + * @param {string} xml + * @returns {Object} @see _getCheckReturn + */ +function checkXML(xml) { + if (typeof window.DOMParser != 'undefined') { + var xmlDoc = (new window.DOMParser()).parseFromString(xml, 'text/xml'); + var error = xmlDoc.getElementsByTagName('parsererror'); + if (error.length > 0) { + return _getCheckReturn(false, parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), error[0].innerHTML); + } + } else if (typeof window.ActiveXObject != 'undefined' && new window.ActiveXObject('Microsoft.XMLDOM')) { + var xmlDocIE = new window.ActiveXObject('Microsoft.XMLDOM'); + xmlDocIE.async = 'false'; + xmlDocIE.loadXML(xml); + if (xmlDocIE.parseError.line > 0) { + return _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason); + } + } + return _getCheckReturn(true); +} +/** + * Formats some XML so that it has proper indentation and structure. + * + * @param {string} xml + * @returns {string} formatted xml + */ +function formatXML(xml) { + // do nothing if an inline script is present to avoid breaking it + if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) { + return xml; + } + return window.vkbeautify.xml(xml, 4); +} +/** + * Checks the syntax validity of some SCSS. + * + * @param {string} scss + * @returns {Object} @see _getCheckReturn + */ +var checkSCSS = (function () { + var mapping = { + '{': '}', '}': '{', + '(': ')', ')': '(', + '[': ']', ']': '[', + }; + var openings = ['{', '(', '[']; + var closings = ['}', ')', ']']; + + return function (scss) { + var stack = []; + var line = 1; + for (var i = 0 ; i < scss.length ; i++) { + if (_.contains(openings, scss[i])) { + stack.push(scss[i]); + } else if (_.contains(closings, scss[i])) { + if (stack.pop() !== mapping[scss[i]]) { + return _getCheckReturn(false, line, _t("Unexpected ") + scss[i]); + } + } else if (scss[i] === '\n') { + line++; + } + } + if (stack.length > 0) { + return _getCheckReturn(false, line, _t("Expected ") + mapping[stack.pop()]); + } + return _getCheckReturn(true); + }; +})(); +/** + * Formats some SCSS so that it has proper indentation and structure. + * + * @todo Right now, this does return the given SCSS content, untouched. + * @param {string} scss + * @returns {string} formatted scss + */ +function formatSCSS(scss) { + return scss; +} + +/** + * Allows to visualize resources (by default, XML views) and edit them. + */ +var ViewEditor = Widget.extend({ + template: 'web_editor.ace_view_editor', + xmlDependencies: ['/web_editor/static/src/xml/ace.xml'], + 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 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' + }, + + /** + * Initializes the parameters so that the ace editor knows which information + * it has to load. + * + * @constructor + * @param {Widget} parent + * @param {string|integer} viewKey + * xml_id or id of the view whose linked resources have to be loaded. + * @param {Object} [options] + * @param {string|integer} [options.initialResID] + * a specific view ID / SCSS URL to load on start (otherwise the main + * view ID associated with the specified viewKey will be used) + * @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 {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', + defaultBundlesRestriction: [], + }); + + this.resources = {xml: {}, scss: {}, js: {}}; + this.editingSessions = {xml: {}, scss: {}, js: {}}; + 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. + * It also loads the resources to visualize (@see _loadResources). + * + * @override + */ + willStart: function () { + return Promise.all([ + this._super.apply(this, arguments), + this._loadResources() + ]); + }, + /** + * Initializes the library and initial view once the DOM is ready. It also + * initializes the resize feature of the ace editor. + * + * @override + */ + start: function () { + this.$viewEditor = this.$('#ace-view-editor'); + + this.$typeSwitcherChoices = this.$('.o_ace_type_switcher_choice'); + this.$typeSwitcherBtn = this.$('.o_ace_type_switcher > .dropdown-toggle'); + + this.$lists = { + xml: this.$('#ace-view-list'), + scss: this.$('#ace-scss-list'), + js: this.$('#ace-js-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]'); + + this.aceEditor = window.ace.edit(this.$viewEditor[0]); + this.aceEditor.setTheme('ace/theme/monokai'); + this.$editor = this.$('.ace_editor'); + + var refX = 0; + var resizing = false; + var minWidth = 400; + var debounceStoreEditorWidth = _.debounce(storeEditorWidth, 500); + + this._updateViewSelectDOM(); + + var initResID; + 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'; + } + } 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; + } else { + var view = _.findWhere(this.views, {xml_id: this.viewKey}); + if (!view) { + view = _.findWhere(this.views, {key: this.viewKey}); + } + initResID = view.id; + } + initType = 'xml'; + } + } + if (initResID) { + this._displayResource(initResID, initType); + } + + if (!this.sortedViews.length || !this.sortedSCSS.length) { + _.defer((function () { + this._switchType(this.sortedViews.length ? 'xml' : 'scss'); + this.$typeSwitcherBtn.parent('.btn-group').addClass('d-none'); + }).bind(this)); + } + + $(document).on('mouseup.ViewEditor', stopResizing.bind(this)).on('mousemove.ViewEditor', updateWidth.bind(this)); + if (this.options.position === 'left') { + this.$('.ace_scroller').after($('<div>').addClass('ace_resize_bar')); + this.$('.ace_gutter').css({'cursor': 'default'}); + this.$el.on('mousedown.ViewEditor', '.ace_resize_bar', startResizing.bind(this)); + } else { + this.$el.on('mousedown.ViewEditor', '.ace_gutter', startResizing.bind(this)); + } + + resizeEditor.call(this, readEditorWidth.call(this)); + + return this._super.apply(this, arguments); + + function resizeEditor(target) { + var width = Math.min(document.body.clientWidth, Math.max(parseInt(target, 10), minWidth)); + this.$editor.width(width); + this.aceEditor.resize(); + this.$el.width(width); + } + function storeEditorWidth() { + localStorage.setItem('ace_editor_width', this.$el.width()); + } + function readEditorWidth() { + var width = localStorage.getItem('ace_editor_width'); + return parseInt(width || 720, 10); + } + function startResizing(e) { + refX = e.pageX; + resizing = true; + } + function stopResizing() { + resizing = false; + } + function updateWidth(e) { + if (!resizing) return; + + var offset = e.pageX - refX; + if (this.options.position === 'left') { + offset = - offset; + } + var width = this.$el.width() - offset; + refX = e.pageX; + resizeEditor.call(this, width); + debounceStoreEditorWidth.call(this); + } + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$el.off('.ViewEditor'); + $(document).off('.ViewEditor'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Initializes a text editor for the specified resource. + * + * @private + * @param {integer|string} resID - the ID/URL of the view/scss/js file + * @param {string} [type] (default to the currently selected one) + * @returns {ace.EditSession} + */ + _buildEditingSession: function (resID, type) { + var self = this; + type = type || this.currentType; + var editingSession = new window.ace.EditSession(this.resources[type][resID].arch); + editingSession.setUseWorker(false); + editingSession.setMode('ace/mode/' + (type || this.currentType)); + editingSession.setUndoManager(new window.ace.UndoManager()); + editingSession.on('change', function () { + _.defer(function () { + self._toggleDirtyInfo(resID); + self._showErrorLine(); + }); + }); + return editingSession; + }, + /** + * Forces the view/scss/js 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') + */ + _displayResource: function (resID, type) { + if (type) { + this._switchType(type); + } + + var editingSession = this.editingSessions[this.currentType][resID]; + if (!editingSession) { + editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID); + } + this.aceEditor.setSession(editingSession); + + if (this.currentType === 'xml') { + this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key)); + } else if (this.currentType === 'scss') { + this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID)); + } else { + this.$viewID.text(_.str.sprintf(_t("JS file: %s"), resID)); + } + const isCustomized = this._isCustomResource(resID); + this.$lists[this.currentType].select2('val', resID); + + this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !isCustomized); + + this.$warningMessage.toggleClass('d-none', + this.currentType !== 'xml' && (resID.indexOf('/user_custom_') >= 0) || isCustomized); + + this.aceEditor.resize(true); + }, + /** + * Formats the current resource being vizualized. + * (@see formatXML, formatSCSS) + * + * @private + */ + _formatResource: function () { + var res = this.aceEditor.getValue(); + var check = (this.currentType === 'xml' ? checkXML : checkSCSS)(res); + if (check.isValid) { + this.aceEditor.setValue((this.currentType === 'xml' ? formatXML : formatSCSS)(res)); + } else { + this._showErrorLine(check.error.line, check.error.message, this._getSelectedResource()); + } + }, + /** + * Returns the currently selected resource data. + * + * @private + * @returns {integer|string} view ID or scss file URL + */ + _getSelectedResource: function () { + var value = this.$lists[this.currentType].select2('val'); + return parseInt(value, 10) || value; + }, + /** + * Checks resource is customized or not. + * + * @private + * @param {integer|string} resID + */ + _isCustomResource(resID) { + // TODO we should be able to detect if the XML template is customized + // to not show the warning in that case + let isCustomized = false; + if (this.currentType === 'scss') { + isCustomized = this.scss[resID].customized; + } else if (this.currentType === 'js') { + isCustomized = this.js[resID].customized; + } + return isCustomized; + }, + /** + * Loads data the ace editor will vizualize and process it. Default behavior + * is loading the activate views, index them and build their hierarchy. + * + * @private + * @returns {Promise} + */ + _loadResources: function () { + // Reset resources + this.resources = {xml: {}, scss: {}, js: {}}; + this.editingSessions = {xml: {}, scss: {}, js: {}}; + this.views = this.resources.xml; + this.scss = this.resources.scss; + this.js = this.resources.js; + + // Load resources + return this._rpc({ + route: '/web_editor/get_assets_editor_resources', + params: { + 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', + }, + }).then((function (resources) { + _processViews.call(this, resources.views || []); + _processJSorSCSS.call(this, resources.scss || [], 'scss'); + _processJSorSCSS.call(this, resources.js || [], 'js'); + }).bind(this)); + + function _processViews(views) { + // Only keep the active views and index them by ID. + _.extend(this.views, _.indexBy(_.filter(views, function (view) { + return view.active; + }), 'id')); + + // Initialize a 0 level for each view and assign them an array containing their children. + var self = this; + var roots = []; + _.each(this.views, function (view) { + view.level = 0; + view.children = []; + }); + _.each(this.views, function (view) { + var parentId = view.inherit_id[0]; + var parent = parentId && self.views[parentId]; + if (parent) { + parent.children.push(view); + } else { + roots.push(view); + } + }); + + // Assign the correct level based on children key and save a sorted array where + // each view is followed by their children. + this.sortedViews = []; + function visit(view, level) { + view.level = level; + self.sortedViews.push(view); + _.each(view.children, function (child) { + visit(child, level + 1); + }); + } + _.each(roots, function (root) { + visit(root, 0); + }); + } + + 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; + } + + // 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) { + _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; }); + _.extend(resources, _.indexBy(bundleInfos[1], 'url')); + }); + } + }, + /** + * Forces the view/scss/js 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 + * + * @private + * @param {integer|string} [resID] (default to the currently selected one) + * @param {string} [type] (default to the currently selected one) + * @returns {Promise} + */ + _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")); + } else { + var resource = type === 'scss' ? this.scss[resID] : this.js[resID]; + return this._rpc({ + route: '/web_editor/reset_asset', + params: { + url: resID, + bundle_xmlid: resource.bundle_xmlid, + }, + }); + } + }, + /** + * Saves a unique SCSS or JS 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 + * error occured. + */ + _saveSCSSorJS: function (session) { + 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', + params: { + url: session.id, + bundle_xmlid: bundleXmlID, + content: session.text, + file_type: fileType, + }, + }).then(function () { + self._toggleDirtyInfo(session.id, fileType, false); + }); + }, + /** + * 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 + * error occured. + */ + _saveResources: function () { + var self = this; + var toSave = {}; + var errorFound = false; + _.each(this.editingSessions, (function (editingSessions, type) { + if (errorFound) return; + + var dirtySessions = _.pick(editingSessions, function (session) { + return session.getUndoManager().hasUndo(); + }); + toSave[type] = _.map(dirtySessions, function (session, resID) { + return { + id: parseInt(resID, 10) || resID, + text: session.getValue(), + }; + }); + + this._showErrorLine(); + for (var i = 0 ; i < toSave[type].length && !errorFound ; i++) { + var check = (type === 'xml' ? checkXML : checkSCSS)(toSave[type][i].text); + if (!check.isValid) { + this._showErrorLine(check.error.line, check.error.message, toSave[type][i].id, type); + errorFound = toSave[type][i]; + } + } + }).bind(this)); + if (errorFound) return Promise.reject(errorFound); + + var defs = []; + var mutex = new concurrency.Mutex(); + _.each(toSave, (function (_toSave, type) { + // Child views first as COW on a parent would delete them + _toSave = _.sortBy(_toSave, 'id').reverse(); + _.each(_toSave, function (session) { + defs.push(mutex.exec(function () { + return (type === 'xml' ? self._saveView(session) : self._saveSCSSorJS(session)); + })); + }); + }).bind(this)); + + var self = this; + return Promise.all(defs).guardedCatch(function (results) { + // some overrides handle errors themselves + if (results === undefined) { + return; + } + var error = results[1]; + Dialog.alert(self, '', { + 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.") + + '<br/>' + + error + ) + }); + }); + }, + /** + * 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 + * error occured. + */ + _saveView: function (session) { + 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); + }); + }); + }, + /** + * Shows a line which produced an error. Red color is added to the editor, + * the cursor move to the line and a message is opened on click on the line + * number. If called without argument, the effects are removed. + * + * @private + * @param {integer} [line] - the line number to highlight + * @param {string} [message] - to show on click on the line number + * @param {integer|string} [resID] + * @param {string} [type] + */ + _showErrorLine: function (line, message, resID, type) { + if (line === undefined || line <= 0) { + if (this.$errorLine) { + this.$errorLine.removeClass('o_error'); + this.$errorLine.off('.o_error'); + this.$errorLine = undefined; + this.$errorContent.removeClass('o_error'); + this.$errorContent = undefined; + } + return; + } + + if (type) this._switchType(type); + + if (this._getSelectedResource() === resID) { + __showErrorLine.call(this, line); + } else { + var onChangeSession = (function () { + this.aceEditor.off('changeSession', onChangeSession); + _.delay(__showErrorLine.bind(this, line), 400); + }).bind(this); + this.aceEditor.on('changeSession', onChangeSession); + this._displayResource(resID, this.currentType); + } + + function __showErrorLine(line) { + this.aceEditor.gotoLine(line); + this.$errorLine = this.$viewEditor.find('.ace_gutter-cell').filter(function () { + return parseInt($(this).text()) === line; + }).addClass('o_error'); + this.$errorLine.addClass('o_error').on('click.o_error', function () { + var $message = $('<div/>').html(message); + $message.text($message.text()); + Dialog.alert(this, "", {$content: $message}); + }); + this.$errorContent = this.$viewEditor.find('.ace_scroller').addClass('o_error'); + } + }, + /** + * Switches to the SCSS, XML or JS edition. Calling this method will adapt all + * DOM elements to keep the editor consistent. + * + * @private + * @param {string} type - either 'xml', 'scss' or 'js' + */ + _switchType: function (type) { + this.currentType = type; + this.$typeSwitcherBtn.html(this.$typeSwitcherChoices.filter('[data-type=' + type + ']').html()); + _.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'); + }, + /** + * Updates the select option DOM element associated with a particular resID + * to indicate if the option is dirty or not. + * + * @private + * @param {integer|string} resID + * @param {string} [type] (default to the currently selected one) + * @param {boolean} [isDirty] true if the view is dirty, default to content + * of UndoManager + */ + _toggleDirtyInfo: function (resID, type, isDirty) { + type = type || this.currentType; + + if (!resID || !this.editingSessions[type][resID]) return; + + var $option = this.$lists[type].find('[value="' + resID + '"]'); + if (isDirty === undefined) { + isDirty = this.editingSessions[type][resID].getUndoManager().hasUndo(); + } + $option.data('dirty', isDirty); + }, + /** + * Renders the content of the view/file <select/> DOM element according to + * current widget data. + * + * @private + */ + _updateViewSelectDOM: function () { + var currentId = this._getSelectedResource(); + + var self = this; + this.$lists.xml.empty(); + _.each(this.sortedViews, function (view) { + self.$lists.xml.append($('<option/>', { + value: view.id, + text: view.name, + selected: currentId === view.id, + 'data-level': view.level, + 'data-debug': view.xml_id, + })); + }); + + this.$lists.scss.empty(); + _populateList(this.sortedSCSS, this.$lists.scss, 5); + + this.$lists.js.empty(); + _populateList(this.sortedJS, this.$lists.js, 3); + + this.$lists.xml.select2('destroy'); + this.$lists.xml.select2({ + formatResult: _formatDisplay.bind(this, false), + formatSelection: _formatDisplay.bind(this, true), + }); + this.$lists.xml.data('select2').dropdown.addClass('o_ace_select2_dropdown'); + this.$lists.scss.select2('destroy'); + this.$lists.scss.select2({ + formatResult: _formatDisplay.bind(this, false), + 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); + + var text = data.text || ''; + if (!isSelected) { + text = Array(($elem.data('level') || 0) + 1).join('-') + ' ' + text; + } + var $div = $('<div/>', { + text: text, + class: 'o_ace_select2_result', + }); + + if ($elem.data('dirty') || $elem.data('customized')) { + $div.prepend($('<span/>', { + class: 'mr8 fa fa-floppy-o ' + ($elem.data('dirty') ? 'text-warning' : 'text-success'), + })); + } + + if (!isSelected && config.isDebug() && $elem.data('debug')) { + $div.append($('<span/>', { + text: ' (' + $elem.data('debug') + ')', + class: 'ml4 small text-muted', + })); + } + + return $div; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the close button is clicked -> hides the ace editor. + * + * @private + */ + _onCloseClick: function () { + this.do_hide(); + }, + /** + * Called when the format button is clicked -> format the current resource. + * + * @private + */ + _onFormatClick: function () { + this._formatResource(); + }, + /** + * Called when a filter dropdown item is cliked. Reload the resources + * according to the new filter and make it visually active. + * + * @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'); + } + this._loadResources().then(this._updateViewSelectDOM.bind(this)); + }, + /** + * Called when another resource is selected -> displays it. + * + * @private + */ + _onResChange: function () { + this._displayResource(this._getSelectedResource()); + }, + /** + * Called when the reset button is clicked -> resets the resources to its + * original standard odoo state. + * + * @private + */ + _onResetClick: function () { + var self = this; + Dialog.confirm(this, _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."), { + title: _t("Careful !"), + confirm_callback: function () { + self._resetResource(self._getSelectedResource()); + }, + }); + }, + /** + * Called when the save button is clicked -> saves the dirty resources and + * reloads. + * + * @private + */ + _onSaveClick: function (ev) { + const restore = dom.addButtonLoadingEffect(ev.currentTarget); + this._saveResources().then(restore).guardedCatch(restore); + }, + /** + * Called when the user wants to switch from xml to scss or vice-versa -> + * adapt resources choices and displays a resource of that type. + * + * @private + * @param {Event} ev + */ + _onTypeChoice: function (ev) { + 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/common/utils.js b/addons/web_editor/static/src/js/common/utils.js new file mode 100644 index 00000000..1cd10318 --- /dev/null +++ b/addons/web_editor/static/src/js/common/utils.js @@ -0,0 +1,266 @@ +odoo.define('web_editor.utils', function (require) { +'use strict'; + +const {ColorpickerWidget} = require('web.Colorpicker'); + +/** + * window.getComputedStyle cannot work properly with CSS shortcuts (like + * 'border-width' which is a shortcut for the top + right + bottom + left border + * widths. If an option wants to customize such a shortcut, it should be listed + * here with the non-shortcuts property it stands for, in order. + * + * @type {Object<string[]>} + */ +const CSS_SHORTHANDS = { + 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'], + 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'], + 'border-color': ['border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color'], + 'border-style': ['border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style'], +}; +/** + * Key-value mapping to list converters from an unit A to an unit B. + * - The key is a string in the format '$1-$2' where $1 is the CSS symbol of + * unit A and $2 is the CSS symbol of unit B. + * - The value is a function that converts the received value (expressed in + * unit A) to another value expressed in unit B. Two other parameters is + * received: the css property on which the unit applies and the jQuery element + * on which that css property may change. + */ +const CSS_UNITS_CONVERSION = { + 's-ms': () => 1000, + 'ms-s': () => 0.001, + 'rem-px': () => _computePxByRem(), + 'px-rem': () => _computePxByRem(true), +}; +/** + * Colors of the default palette, used for substitution in shapes/illustrations. + * key: number of the color in the palette (ie, o-color-<1-5>) + * value: color hex code + */ +const DEFAULT_PALETTE = { + '1': '#3AADAA', + '2': '#7C6576', + '3': '#F6F6F6', + '4': '#FFFFFF', + '5': '#383E45', +}; + +/** + * Computes the number of "px" needed to make a "rem" unit. Subsequent calls + * returns the cached computed value. + * + * @param {boolean} [toRem=false] + * @returns {float} - number of px by rem if 'toRem' is false + * - the inverse otherwise + */ +function _computePxByRem(toRem) { + if (_computePxByRem.PX_BY_REM === undefined) { + const htmlStyle = window.getComputedStyle(document.documentElement); + _computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']); + } + return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM; +} +/** + * Converts the given (value + unit) string to a numeric value expressed in + * the other given css unit. + * + * e.g. fct('400ms', 's') -> 0.4 + * + * @param {string} value + * @param {string} unitTo + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {number} + */ +function _convertValueToUnit(value, unitTo, cssProp, $target) { + const m = _getNumericAndUnit(value); + if (!m) { + return NaN; + } + const numValue = parseFloat(m[0]); + const valueUnit = m[1]; + return _convertNumericToUnit(numValue, valueUnit, unitTo, cssProp, $target); +} +/** + * Converts the given numeric value expressed in the given css unit into + * the corresponding numeric value expressed in the other given css unit. + * + * e.g. fct(400, 'ms', 's') -> 0.4 + * + * @param {number} value + * @param {string} unitFrom + * @param {string} unitTo + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {number} + */ +function _convertNumericToUnit(value, unitFrom, unitTo, cssProp, $target) { + if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) { + return value; + } + const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`]; + if (converter === undefined) { + throw new Error(`Cannot convert '${unitFrom}' units into '${unitTo}' units !`); + } + return value * converter(cssProp, $target); +} +/** + * Returns the numeric value and unit of a css value. + * + * e.g. fct('400ms') -> [400, 'ms'] + * + * @param {string} value + * @returns {Array|null} + */ +function _getNumericAndUnit(value) { + const m = value.trim().match(/^(-?[0-9.]+)([A-Za-z% -]*)$/); + if (!m) { + return null; + } + return [m[1].trim(), m[2].trim()]; +} +/** + * Checks if two css values are equal. + * + * @param {string} value1 + * @param {string} value2 + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {boolean} + */ +function _areCssValuesEqual(value1, value2, cssProp, $target) { + // String comparison first + if (value1 === value2) { + return true; + } + + // It could be a CSS variable, in that case the actual value has to be + // retrieved before comparing. + if (value1.startsWith('var(--')) { + value1 = _getCSSVariableValue(value1.substring(6, value1.length - 1)); + } + if (value2.startsWith('var(--')) { + value2 = _getCSSVariableValue(value2.substring(6, value2.length - 1)); + } + if (value1 === value2) { + return true; + } + + // They may be colors, normalize then re-compare the resulting string + const color1 = ColorpickerWidget.normalizeCSSColor(value1); + const color2 = ColorpickerWidget.normalizeCSSColor(value2); + if (color1 === color2) { + return true; + } + + // Convert the second value in the unit of the first one and compare + // floating values + const data = _getNumericAndUnit(value1); + if (!data) { + return false; + } + const numValue1 = data[0]; + const numValue2 = _convertValueToUnit(value2, data[1], cssProp, $target); + return (Math.abs(numValue1 - numValue2) < Number.EPSILON); +} +/** + * @param {string|number} name + * @returns {boolean} + */ +function _isColorCombinationName(name) { + const number = parseInt(name); + return (!isNaN(number) && number % 100 !== 0); +} +/** + * @param {string[]} colorNames + * @param {string} [prefix='bg-'] + * @returns {string[]} + */ +function _computeColorClasses(colorNames, prefix = 'bg-') { + let hasCCClasses = false; + const isBgPrefix = (prefix === 'bg-'); + const classes = colorNames.map(c => { + if (isBgPrefix && _isColorCombinationName(c)) { + hasCCClasses = true; + return `o_cc${c}`; + } + return (prefix + c); + }); + if (hasCCClasses) { + classes.push('o_cc'); + } + return classes; +} +/** + * @param {string} key + * @param {CSSStyleDeclaration} [htmlStyle] if not provided, it is computed + * @returns {string} + */ +function _getCSSVariableValue(key, htmlStyle) { + if (htmlStyle === undefined) { + htmlStyle = window.getComputedStyle(document.documentElement); + } + // Get trimmed value from the HTML element + let value = htmlStyle.getPropertyValue(`--${key}`).trim(); + // If it is a color value, it needs to be normalized + value = ColorpickerWidget.normalizeCSSColor(value); + // Normally scss-string values are "printed" single-quoted. That way no + // magic conversation is needed when customizing a variable: either save it + // quoted for strings or non quoted for colors, numbers, etc. However, + // Chrome has the annoying behavior of changing the single-quotes to + // double-quotes when reading them through getPropertyValue... + return value.replace(/"/g, "'"); +} +/** + * Normalize a color in case it is a variable name so it can be used outside of + * css. + * + * @param {string} color the color to normalize into a css value + * @returns {string} the normalized color + */ +function _normalizeColor(color) { + if (ColorpickerWidget.isCSSColor(color)) { + return color; + } + return _getCSSVariableValue(color); +} +/** + * Parse an element's background-image's url. + * + * @param {string} string a css value in the form 'url("...")' + * @returns {string|false} the src of the image or false if not parsable + */ +function _getBgImageURL(el) { + const string = $(el).css('background-image'); + const match = string.match(/^url\((['"])(.*?)\1\)$/); + if (!match) { + return ''; + } + const matchedURL = match[2]; + // Make URL relative if possible + const fullURL = new URL(matchedURL, window.location.origin); + if (fullURL.origin === window.location.origin) { + return fullURL.href.slice(fullURL.origin.length); + } + return matchedURL; +} + +return { + CSS_SHORTHANDS: CSS_SHORTHANDS, + CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION, + DEFAULT_PALETTE: DEFAULT_PALETTE, + computePxByRem: _computePxByRem, + convertValueToUnit: _convertValueToUnit, + convertNumericToUnit: _convertNumericToUnit, + getNumericAndUnit: _getNumericAndUnit, + areCssValuesEqual: _areCssValuesEqual, + isColorCombinationName: _isColorCombinationName, + computeColorClasses: _computeColorClasses, + getCSSVariableValue: _getCSSVariableValue, + normalizeColor: _normalizeColor, + getBgImageURL: _getBgImageURL, +}; +}); |
