summaryrefslogtreecommitdiff
path: root/addons/web_editor/static/src/js/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web_editor/static/src/js/widgets')
-rw-r--r--addons/web_editor/static/src/js/widgets/ace.js693
-rw-r--r--addons/web_editor/static/src/js/widgets/widgets.js1177
2 files changed, 1870 insertions, 0 deletions
diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js
new file mode 100644
index 00000000000..b1b28d78e83
--- /dev/null
+++ b/addons/web_editor/static/src/js/widgets/ace.js
@@ -0,0 +1,693 @@
+odoo.define('web_editor.ace', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var Dialog = require("web.Dialog");
+var Widget = require('web.Widget');
+var base = require('web_editor.base');
+var local_storage = require('web.local_storage');
+var session = require("web.session");
+
+var qweb = core.qweb;
+var _t = core._t;
+
+ajax.loadXML('/web_editor/static/src/xml/ace.xml', qweb);
+
+function _getCheckReturn(isValid, errorLine, errorMessage) {
+ return {
+ isValid: isValid,
+ error: isValid ? null : {
+ line: errorLine,
+ message: errorMessage,
+ },
+ };
+}
+
+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);
+}
+
+function formatXML(xml) {
+ return window.vkbeautify.xml(xml, 4);
+}
+
+var checkLESS = (function () {
+ var mapping = {
+ '{': '}', '}': '{',
+ '(': ')', ')': '(',
+ '[': ']', ']': '[',
+ };
+ var openings = ['{', '(', '['];
+ var closings = ['}', ')', ']'];
+
+ return function (less) {
+ var stack = [];
+ var line = 1;
+ for (var i = 0 ; i < less.length ; i++) {
+ if (_.contains(openings, less[i])) {
+ stack.push(less[i]);
+ } else if (_.contains(closings, less[i])) {
+ if (stack.pop() !== mapping[less[i]]) {
+ return _getCheckReturn(false, line, _t("Unexpected ") + less[i]);
+ }
+ } else if (less[i] === '\n') {
+ line++;
+ }
+ }
+ if (stack.length > 0) {
+ return _getCheckReturn(false, line, _t("Expected ") + mapping[stack.pop()]);
+ }
+ return _getCheckReturn(true);
+ };
+})();
+
+function formatLESS(less) {
+ return less;
+}
+
+/**
+ * The ViewEditor Widget allow to visualize resources (by default, XML views) and edit them.
+ */
+var ViewEditor = Widget.extend({
+ template: 'web_editor.ace_view_editor',
+ events: {
+ "click .o_ace_type_switcher_choice": function (e) {
+ e.preventDefault();
+ this.switchType($(e.target).data("type"));
+ },
+ 'change .o_res_list': function () {
+ this.displayResource(this.selectedResource());
+ },
+ 'click .js_include_bundles': function (e) {
+ this.options.includeBundles = $(e.target).prop("checked");
+ this.loadResources().then(this._updateViewSelectDOM.bind(this));
+ },
+ 'click .js_include_all_less': function (e) {
+ this.options.includeAllLess = $(e.target).prop("checked");
+ this.loadResources().then(this._updateViewSelectDOM.bind(this));
+ },
+ 'click button[data-action=save]': 'saveResources',
+ "click button[data-action=\"reset\"]": 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.selectedResource());
+ },
+ });
+ },
+ 'click button[data-action=format]': 'formatResource',
+ 'click button[data-action=close]': 'do_hide',
+ },
+ /**
+ * The init method should initialize the parameters of which information the ace editor will
+ * have to load.
+ * @param parent: the parent element of the editor widget.
+ * @param viewKey: xml_id of the view whose linked resources are to be loaded.
+ Also allow to receive the id directly.
+ * @param options: an object containing some options
+ * - initialResID: a specific view ID to load on start (otherwise the main view ID
+ * associated with the specified viewKey will be used).
+ * - includeBundles: whether or not the assets bundles templates needs to be loaded.
+ */
+ init: function (parent, viewKey, options) {
+ this._super.apply(this, arguments);
+
+ this.viewKey = viewKey;
+ this.options = _.defaults({}, options, {
+ position: 'right',
+ doNotLoadViews: false,
+ doNotLoadLess: false,
+ includeBundles: false,
+ includeAllLess: false,
+ defaultBundlesRestriction: [],
+ });
+
+ this.resources = {xml: {}, less: {}};
+ this.editingSessions = {xml: {}, less: {}};
+ this.currentType = "xml";
+
+ // Alias
+ this.views = this.resources.xml;
+ this.less = this.resources.less;
+ },
+ /**
+ * The willStart method is in charge of loading everything the ace library needs to work.
+ * It also loads the resources to visualize. See @loadResources.
+ */
+ willStart: function () {
+ var js_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () {
+ return $.when(
+ ajax.loadJS('/web/static/lib/ace/mode-xml.js'),
+ ajax.loadJS('/web/static/lib/ace/mode-less.js'),
+ ajax.loadJS('/web/static/lib/ace/theme-monokai.js')
+ );
+ });
+ return $.when(this._super.apply(this, arguments), js_def, this.loadResources());
+ },
+ /**
+ * The start method is in charge of initializing the library and initial view once the DOM is
+ * ready. It also initializes the resize feature of the ace editor.
+ * @return a deferred which is resolved when the widget DOM content is fully loaded.
+ */
+ start: function () {
+ this.$viewEditor = this.$("#ace-view-editor");
+ this.$editor = this.$(".ace_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"),
+ less: this.$("#ace-less-list")
+ };
+ this.$includeBundlesArea = this.$(".oe_include_bundles");
+ this.$includeAllLessArea = this.$(".o_include_all_less");
+ this.$viewID = this.$("#ace-view-id > span");
+
+ 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");
+
+ 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;
+ initType = (_.isString(initResID) && initResID[0] === '/') ? "less" : "xml";
+ } else {
+ if (!this.options.doNotLoadLess) {
+ initResID = this.sorted_less[0][1][0].url; // first bundle, less files, first one
+ initType = "less";
+ }
+ if (!this.options.doNotLoadViews) {
+ initResID = (typeof this.viewKey === "number" ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id);
+ initType = "xml";
+ }
+ }
+ if (initResID) {
+ this.displayResource(initResID, initType);
+ }
+
+ if (!this.sorted_views.length || !this.sorted_less.length) {
+ _.defer((function () {
+ this.switchType(this.sorted_views.length ? "xml" : "less");
+ this.$typeSwitcherBtn.parent(".btn-group").addClass("hidden");
+ }).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() {
+ local_storage.setItem('ace_editor_width', this.$el.width());
+ }
+ function readEditorWidth() {
+ var width = local_storage.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);
+ }
+ },
+ /**
+ * The destroy method unbinds custom events binded to the document element.
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ this.$el.off(".ViewEditor");
+ $(document).off(".ViewEditor");
+ },
+ /**
+ * The loadResources method is in charge of loading data the ace editor will vizualize
+ * and of processing them.
+ * Default behavior is loading the activate views, index them and build their hierarchy.
+ * @return a deferred which is resolved once everything is loaded and processed.
+ */
+ loadResources: function () {
+ // Reset resources
+ this.resources = {xml: {}, less: {}};
+ this.editingSessions = {xml: {}, less: {}};
+ this.views = this.resources.xml;
+ this.less = this.resources.less;
+
+ // Load resources
+ return this._rpc({
+ route: "/web_editor/get_assets_editor_resources",
+ params: {
+ key: this.viewKey,
+ get_views: !this.options.doNotLoadViews,
+ get_less: !this.options.doNotLoadLess,
+ bundles: this.options.includeBundles,
+ bundles_restriction: this.options.includeAllLess ? [] : this.options.defaultBundlesRestriction,
+ },
+ }).then((function (resources) {
+ _process_views.call(this, resources.views);
+ _process_less.call(this, resources.less);
+ }).bind(this));
+
+ function _process_views(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.sorted_views = [];
+ function visit(view, level) {
+ view.level = level;
+ self.sorted_views.push(view);
+ _.each(view.children, function (child) {
+ visit(child, level + 1);
+ });
+ }
+ _.each(roots, function (root) {
+ visit(root, 0);
+ });
+ }
+
+ function _process_less(less) {
+ // The received less data is already sorted by bundle and DOM order
+ this.sorted_less = less;
+
+ // Store the URL ungrouped by bundle and use the URL as key (resource ID)
+ var self = this;
+ _.each(less, function (bundleInfos) {
+ _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; });
+ _.extend(self.less, _.indexBy(bundleInfos[1], "url"));
+ });
+ }
+ },
+ /**
+ * The private _updateViewSelectDOM method purpose is to render the content of the view/file
+ * select DOM element according to current widget data.
+ */
+ _updateViewSelectDOM: function () {
+ var currentId = this.selectedResource();
+
+ var self = this;
+ this.$lists.xml.empty();
+ _.each(this.sorted_views, 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.less.empty();
+ _.each(this.sorted_less, function (bundleInfos) {
+ var $optgroup = $("<optgroup/>", {
+ label: bundleInfos[0].name,
+ }).appendTo(self.$lists.less);
+ _.each(bundleInfos[1], function (lessInfo) {
+ var name = lessInfo.url.substring(_.lastIndexOf(lessInfo.url, "/") + 1, lessInfo.url.length - 5);
+ $optgroup.append($("<option/>", {
+ value: lessInfo.url,
+ text: name,
+ selected: currentId === lessInfo.url,
+ "data-debug": lessInfo.url,
+ "data-customized": lessInfo.customized
+ }));
+ });
+ });
+
+ this.$lists.xml.select2("destroy");
+ this.$lists.xml.select2({
+ formatResult: _formatDisplay,
+ formatSelection: _formatDisplay,
+ });
+ this.$lists.less.select2("destroy");
+ this.$lists.less.select2({
+ formatResult: _formatDisplay,
+ formatSelection: _formatDisplay,
+ });
+
+ function _formatDisplay(data) {
+ var $elem = $(data.element);
+
+ var $div = $("<div/>", {
+ text: data.text || "",
+ style: "padding: 0 0 0 " + (24 * $elem.data("level")) + "px",
+ });
+
+ if ($elem.data("dirty") || $elem.data("customized")) {
+ $div.prepend($("<span/>", {
+ class: "fa fa-floppy-o " + ($elem.data("dirty") ? "text-warning" : "text-success"),
+ style: "margin-right: 8px;",
+ }));
+ }
+
+ if (session.debug && $elem.data("debug")) {
+ $div.append($("<span/>", {
+ text: " (" + $elem.data("debug") + ")",
+ class: "text-muted",
+ style: "font-size: 80%",
+ }));
+ }
+
+ return $div;
+ }
+ },
+ /**
+ * The switchType method switches to the LESS or XML edition. Calling this method will adapt all DOM elements to
+ * keep the editor consistent.
+ * @param type: either "xml" or "less"
+ */
+ switchType: function (type) {
+ this.currentType = type;
+ this.$typeSwitcherBtn.html(this.$typeSwitcherChoices.filter("[data-type=\"" + type + "\"]").html());
+ _.each(this.$lists, function ($list, _type) { $list.toggleClass("hidden", type !== _type); });
+ this.$lists[type].change();
+
+ this.$includeBundlesArea.toggleClass("hidden", this.currentType === "less" || !session.debug);
+ this.$includeAllLessArea.toggleClass("hidden", this.currentType === "xml" || !session.debug || this.options.defaultBundlesRestriction.length === 0);
+ this.$formatButton.toggleClass("hidden", this.currentType === "less");
+ },
+ /**
+ * The selectedResource method returns the currently selected resource id (view ID or less file URL).
+ * @return the currently resource id (view ID or less file URL)
+ */
+ selectedResource: function () {
+ return this.$lists[this.currentType].select2("val");
+ },
+ /**
+ * The displayResource method forces the view/less file identified by its ID/URL to be displayed in the editor.
+ * The method will update the resource select DOM element as well.
+ * @param resID: the ID/URL of the view/less file to display
+ * @param type: the type of resource (either "xml" or "less")
+ */
+ 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].xml_id));
+ } else {
+ this.$viewID.text(_.str.sprintf(_t("Less file: %s"), resID));
+ }
+ this.$lists[this.currentType].select2("val", resID);
+
+ this.$resetButton.toggleClass("hidden", this.currentType === "xml" || !this.less[resID].customized);
+ },
+ /**
+ * The resetResource method forces the view/less file identified by its ID/URL to be reset to the way it was before
+ * the user started editing it. TODO view reset is not supported yet
+ * @param resID: the ID/URL of the view/less file to reset (default to the currently selected one)
+ * @param type: the type of the resource to reset (default to the currently selected one)
+ * @return a deferred which is resolved once the resource has been reset
+ */
+ resetResource: function (resID, type) {
+ resID = resID || this.selectedResource();
+ type = type || this.currentType;
+
+ if (this.currentType === "xml") {
+ return $.Defered().reject(_t("Reseting views is not supported yet")); // TODO
+ } else {
+ return this._rpc({
+ route: "/web_editor/reset_less",
+ params: {
+ url: resID,
+ bundle_xmlid: this.less[resID].bundle_xmlid,
+ },
+ });
+ }
+ },
+ /**
+ * The private _buildEditingSession method initializes a text editor for the specified resource.
+ * @param resID: the ID/URL of the view/less file whose text editor it will be
+ * @param type: the type of the given resource (default to the currently selected one)
+ * @return an ace.EditSession object linked to the specified resID.
+ */
+ _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;
+ },
+ /**
+ * The private _toggleDirtyInfo method update the select option DOM element associated with
+ * a particular resID to indicate if the option is dirty or not.
+ * @param resID: the ID/URL of the view/less file whose option has to be updated
+ * @param type: the type of the given resource (default to the currently selected one)
+ * @param isDirty: a boolean to indicate if the view is dirty or not ; 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);
+ },
+ /**
+ * The formatResource method formats the current resource being vizualized.
+ * TODO formatting LESS files is not supported yet.
+ */
+ formatResource: function () {
+ var res = this.aceEditor.getValue();
+ var check = (this.currentType === "xml" ? checkXML : checkLESS)(res);
+ if (check.isValid) {
+ this.aceEditor.setValue((this.currentType === "xml" ? formatXML : formatLESS)(res));
+ } else {
+ this._showErrorLine(check.error.line, check.error.message, this.selectedResource());
+ }
+ },
+ /**
+ * The saveResources method is in charge of saving every resource that has been modified.
+ * If one cannot be saved, none is saved and an error message is displayed.
+ * @return a deferred whose status indicates if the save is finished or if an error occured.
+ */
+ saveResources: function () {
+ 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 : checkLESS)(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 $.Deferred().reject(errorFound);
+
+ var defs = [];
+ _.each(toSave, (function (_toSave, type) {
+ defs = defs.concat(_.map(_toSave, (type === "xml" ? this._saveView : this._saveLess).bind(this)));
+ }).bind(this));
+
+ 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 well-formed.")
+ + "<br/>"
+ + error
+ )
+ });
+ }).bind(this));
+ },
+ /**
+ * The private _saveView method is in charge of saving an unique XML view.
+ * @param session: an object which contains the "id" and the "text" of the view to save.
+ * @return a deferred whose status indicates if the save is finished or if an error occured.
+ */
+ _saveView: function (session) {
+ var def = $.Deferred();
+
+ var self = this;
+ this._rpc({
+ model: 'ir.ui.view',
+ method: 'write',
+ args: [[session.id], {arch: session.text}, _.extend(base.get_context(), {lang: null})],
+ }).then(function () {
+ self._toggleDirtyInfo(session.id, "xml", false);
+ def.resolve();
+ }, function (source, error) {
+ def.reject(session, error);
+ });
+
+ return def;
+ },
+ /**
+ * The private _saveLess method is in charge of saving an unique LESS file.
+ * @param session: an object which contains the "id" (url) and the "text" of the view to save.
+ * @return a deferred whose status indicates if the save is finished or if an error occured.
+ */
+ _saveLess: function (session) {
+ var def = $.Deferred();
+
+ var self = this;
+ this._rpc({
+ route: "/web_editor/save_less",
+ params: {
+ url: session.id,
+ bundle_xmlid: this.less[session.id].bundle_xmlid,
+ content: session.text,
+ },
+ }).then(function () {
+ self._toggleDirtyInfo(session.id, "less", false);
+ def.resolve();
+ }, function (source, error) {
+ def.reject(session, error);
+ });
+
+ return def;
+ },
+ /**
+ * The private _showErrorLine method is designed to show 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 the _showErrorLine is called without argument, the effects are removed.
+ * @param line: the line number to highlight
+ * @param message: the message to show on click on the line number
+ * @param resID: the ID/URL of the view/less file whose line is to highlight
+ * @param type: the type of the given resource
+ */
+ _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.selectedResource() === 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");
+ }
+ },
+});
+
+return ViewEditor;
+});
diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js
new file mode 100644
index 00000000000..3bfae6ce240
--- /dev/null
+++ b/addons/web_editor/static/src/js/widgets/widgets.js
@@ -0,0 +1,1177 @@
+odoo.define('web_editor.widget', function (require) {
+'use strict';
+
+var core = require('web.core');
+var ajax = require('web.ajax');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+var base = require('web_editor.base');
+var rte = require('web_editor.rte');
+
+var QWeb = core.qweb;
+var range = $.summernote.core.range;
+var dom = $.summernote.core.dom;
+
+var _t = core._t;
+
+/**
+ * Extend Dialog class to handle save/cancel of edition components.
+ */
+Dialog = Dialog.extend({
+ init: function (parent, options) {
+ options = options || {};
+ this._super(parent, _.extend({}, {
+ buttons: [
+ {text: options.save_text || _t("Save"), classes: "btn-primary o_save_button", click: this.save},
+ {text: _t("Discard"), close: true}
+ ]
+ }, options));
+
+ this.destroyAction = "cancel";
+
+ var self = this;
+ this.opened().then(function () {
+ self.$('input:first').focus();
+ });
+ this.on("closed", this, function () {
+ this.trigger(this.destroyAction, this.final_data || null);
+ });
+ },
+ save: function () {
+ this.destroyAction = "save";
+ this.close();
+ },
+});
+
+/**
+ * alt widget. Lets users change a alt & title on a media
+ */
+var alt = Dialog.extend({
+ template: 'web_editor.dialog.alt',
+ init: function (parent, options, $editable, media) {
+ this._super(parent, _.extend({}, {
+ title: _t("Change media description and tooltip")
+ }, options));
+ this.$editable = $editable;
+ this.media = media;
+ this.alt = ($(this.media).attr('alt') || "").replace(/&quot;/g, '"');
+ this.title = ($(this.media).attr('title') || "").replace(/&quot;/g, '"');
+ },
+ save: function () {
+ range.createFromNode(this.media).select();
+ this.$editable.data('NoteHistory').recordUndo();
+ var alt = this.$('#alt').val();
+ var title = this.$('#title').val();
+ $(this.media).attr('alt', alt ? alt.replace(/"/g, "&quot;") : null).attr('title', title ? title.replace(/"/g, "&quot;") : null);
+ _.defer((function () {
+ click_event(this.media, "mouseup");
+ }).bind(this));
+ return this._super.apply(this, arguments);
+ },
+});
+
+var click_event = function (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);
+};
+
+/**
+ * MediaDialog widget. Lets users change a media, including uploading a
+ * new image, font awsome or video and can change a media into an other
+ * media
+ *
+ * options: select_images: allow the selection of more of one image
+ */
+var MediaDialog = Dialog.extend({
+ template: 'web_editor.dialog.media',
+ events : _.extend({}, Dialog.prototype.events, {
+ 'input input#icon-search': 'search',
+ }),
+ init: function (parent, options, $editable, media) {
+ this._super(parent, _.extend({}, {
+ title: _t("Select a Media"),
+ }, options));
+ if ($editable) {
+ this.$editable = $editable;
+ this.rte = this.$editable.rte || this.$editable.data('rte');
+ }
+ this.options = options || {};
+ this.old_media = media;
+ this.media = media;
+ this.isNewMedia = !media;
+ this.range = range.create();
+ },
+ start: function () {
+ var self = this;
+
+ this.$modal.addClass('note-image-dialog');
+ this.$modal.find('.modal-dialog').addClass('o_select_media_dialog');
+
+ this.only_images = this.options.only_images || this.options.select_images || (this.media && ($(this.media).parent().data("oe-field") === "image" || $(this.media).parent().data("oe-type") === "image"));
+ if (this.only_images) {
+ this.$('[href="#editor-media-document"], [href="#editor-media-video"], [href="#editor-media-icon"]').addClass('hidden');
+ }
+
+ this.opened((function () {
+ if (this.media) {
+ if (this.media.nodeName === "IMG") {
+ this.$('[href="#editor-media-image"]').tab('show');
+ } else if ($(this.media).is('a.o_image')) {
+ this.$('[href="#editor-media-document"]').tab('show');
+ } else if (this.media.className.match(/(^|\s)media_iframe_video($|\s)/)) {
+ this.$('[href="#editor-media-video"]').tab('show');
+ } else if (this.media.parentNode.className.match(/(^|\s)media_iframe_video($|\s)/)) {
+ this.media = this.media.parentNode;
+ this.$('[href="#editor-media-video"]').tab('show');
+ } else if (this.media.className.match(/(^|\s)fa($|\s)/)) {
+ this.$('[href="#editor-media-icon"]').tab('show');
+ }
+ }
+ }).bind(this));
+
+ this.imageDialog = new ImageDialog(this, this.media, this.options);
+ this.imageDialog.appendTo(this.$("#editor-media-image"));
+ this.documentDialog = new ImageDialog(this, this.media, _.extend({'document': true}, this.options));
+ this.documentDialog.appendTo(this.$("#editor-media-document"));
+ if (!this.only_images) {
+ this.iconDialog = new fontIconsDialog(this, this.media, this.options);
+ this.iconDialog.appendTo(this.$("#editor-media-icon"));
+ this.videoDialog = new VideoDialog(this, this.media, this.options);
+ this.videoDialog.appendTo(this.$("#editor-media-video"));
+ }
+
+ this.active = this.imageDialog;
+
+ this.$('a[data-toggle="tab"]').on('shown.bs.tab', function (event) {
+ if ($(event.target).is('[href="#editor-media-image"]')) {
+ self.active = self.imageDialog;
+ self.$('li.search, li.previous, li.next').removeClass("hidden");
+ } else if ($(event.target).is('[href="#editor-media-document"]')) {
+ self.active = self.documentDialog;
+ self.$('li.search, li.previous, li.next').removeClass("hidden");
+ } else if ($(event.target).is('[href="#editor-media-icon"]')) {
+ self.active = self.iconDialog;
+ self.$('li.search, li.previous, li.next').removeClass("hidden");
+ self.$('.nav-tabs li.previous, .nav-tabs li.next').addClass("hidden");
+ } else if ($(event.target).is('[href="#editor-media-video"]')) {
+ self.active = self.videoDialog;
+ self.$('.nav-tabs li.search').addClass("hidden");
+ }
+ });
+
+ return this._super.apply(this, arguments);
+ },
+ save: function () {
+ if (this.options.select_images) {
+ this.final_data = this.active.save();
+ this._super.apply(this, arguments);
+ return;
+ }
+ if(this.rte) {
+ this.range.select();
+ this.rte.historyRecordUndo(this.media);
+ }
+
+ var self = this;
+ if (self.media) {
+ this.media.innerHTML = "";
+ if (this.active !== this.imageDialog && this.active !== this.documentDialog) {
+ this.imageDialog.clear();
+ }
+ // if not mode only_images
+ if (this.iconDialog && this.active !== this.iconDialog) {
+ this.iconDialog.clear();
+ }
+ if (this.videoDialog && this.active !== this.videoDialog) {
+ this.videoDialog.clear();
+ }
+ } else {
+ this.media = document.createElement("img");
+ this.range.insertNode(this.media, true);
+ this.active.media = this.media;
+ }
+ this.active.save();
+
+ if (this.active.add_class) {
+ $(this.active.media).addClass(this.active.add_class);
+ }
+ var media = this.active.media;
+
+ this.final_data = [media, self.old_media];
+ $(document.body).trigger("media-saved", this.final_data);
+ $(self.old_media).trigger("save", this.final_data);
+
+ // Update editor bar after image edition (in case the image change to icon or other)
+ _.defer(function () {
+ if (!media.parentNode) return;
+ range.createFromNode(media).select();
+ click_event(media, "mousedown");
+ click_event(media, "mouseup");
+ });
+
+ this._super.apply(this, arguments);
+ },
+ searchTimer: null,
+ search: function () {
+ var self = this;
+ var needle = this.$("input#icon-search").val();
+ clearTimeout(this.searchTimer);
+ this.searchTimer = setTimeout(function () {
+ self.active.search(needle || "");
+ },250);
+ }
+});
+
+/**
+ * ImageDialog widget. Let users change an image, including uploading a
+ * new image in odoo or selecting the image style (if supported by
+ * the caller).
+ */
+var ImageDialog = Widget.extend({
+ template: 'web_editor.dialog.image',
+ IMAGES_PER_ROW: 6,
+ IMAGES_ROWS: 2,
+ events: _.extend({}, Dialog.prototype.events, {
+ 'change .url-source': function (e) {
+ this.changed($(e.target));
+ },
+ 'click button.filepicker': function () {
+ var filepicker = this.$('input[type=file]');
+ if (!_.isEmpty(filepicker)) {
+ filepicker[0].click();
+ }
+ },
+ 'click .js_disable_optimization': function () {
+ this.$('input[name="disable_optimization"]').val('1');
+ var filepicker = this.$('button.filepicker');
+ if (!_.isEmpty(filepicker)) {
+ filepicker[0].click();
+ }
+ },
+ 'change input[type=file]': 'file_selection',
+ 'submit form': 'form_submit',
+ 'change input.url': "change_input",
+ 'keyup input.url': "change_input",
+ 'click .existing-attachments [data-src]': 'select_existing',
+ 'dblclick .existing-attachments [data-src]': function () {
+ this.getParent().save();
+ },
+ 'click .o_existing_attachment_remove': 'try_remove',
+ 'keydown.dismiss.bs.modal': function () {},
+ }),
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this.options = options || {};
+ this.accept = this.options.accept || this.options.document ? "*/*" : "image/*";
+ this.domain = this.options.domain || ['|', ['mimetype', '=', false], ['mimetype', this.options.document ? 'not in' : 'in', ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png']]];
+ this.parent = parent;
+ this.old_media = media;
+ this.media = media;
+ this.images = [];
+ this.page = 0;
+ },
+ start: function () {
+ this.$preview = this.$('.preview-container').detach();
+ var self = this;
+ var res = this._super.apply(this, arguments);
+ var o = {url: null, alt: null};
+
+ if ($(this.media).is("img")) {
+ o.url = this.media.getAttribute('src');
+ } else if ($(this.media).is("a.o_image")) {
+ o.url = this.media.getAttribute('href').replace(/[?].*/, '');
+ o.id = +o.url.match(/\/web\/content\/([0-9]*)/, '')[1];
+ }
+ this.parent.$(".pager > li").click(function (e) {
+ if(!self.$el.is(':visible')) {
+ return;
+ }
+ e.preventDefault();
+ var $target = $(e.currentTarget);
+ if ($target.hasClass('disabled')) {
+ return;
+ }
+ self.page += $target.hasClass('previous') ? -1 : 1;
+ self.display_attachments();
+ });
+ this.fetch_existing().then(function () {
+ if (o.url) {
+ self.set_image(_.find(self.records, function (record) { return record.url === o.url;}) || o);
+ }
+ });
+ return res;
+ },
+ push: function (attachment) {
+ if (this.options.select_images) {
+ var img = _.select(this.images, function (v) { return v.id === attachment.id; });
+ if (img.length) {
+ this.images.splice(this.images.indexOf(img[0]),1);
+ } else {
+ this.images.push(attachment);
+ }
+ } else {
+ this.images = [attachment];
+ }
+ },
+ save: function () {
+ if (this.options.select_images) {
+ return this.images;
+ }
+
+ var img = this.images[0];
+ if (!img) {
+ var id = this.$(".existing-attachments [data-src]:first").data('id');
+ img = _.find(this.images, function (img) { return img.id === id;});
+ }
+
+ var media;
+ if (!img.is_document) {
+ if (this.media.tagName !== "IMG" || !this.old_media) {
+ this.add_class = "pull-left";
+ this.style = {"width": "100%"};
+ }
+ if(this.media.tagName !== "IMG") {
+ media = document.createElement('img');
+ $(this.media).replaceWith(media);
+ this.media = media;
+ }
+ this.media.setAttribute('src', img.src);
+ } else {
+ if (this.media.tagName !== "A") {
+ $('.note-control-selection').hide();
+ media = document.createElement('a');
+ $(this.media).replaceWith(media);
+ this.media = media;
+ }
+ this.media.setAttribute('href', '/web/content/' + img.id + '?unique=' + img.checksum + '&download=true');
+ $(this.media).addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype);
+ }
+
+ $(this.media).attr('alt', img.alt);
+ var style = this.style;
+ if (style) { $(this.media).css(style); }
+
+ return this.media;
+ },
+ clear: function () {
+ this.media.className = this.media.className.replace(/(^|\s+)((img(\s|$)|img-(?!circle|rounded|thumbnail))[^\s]*)/g, ' ');
+ },
+ change_input: function (e) {
+ var $input = $(e.target);
+ var $button = $input.parent().find("button");
+ var emptyValue = ($input.val() === "");
+ $button.toggleClass("btn-default", emptyValue).toggleClass("btn-primary", !emptyValue);
+ },
+ search: function (needle) {
+ var self = this;
+ this.fetch_existing(needle).then(function () {
+ self.selected_existing();
+ });
+ },
+ set_image: function (attachment) {
+ this.push(attachment);
+ this.$('input.url').val('');
+ this.search();
+ this.images = [];
+ },
+ form_submit: function (event) {
+ var self = this;
+ var $form = this.$('form[action="/web_editor/attachment/add"]');
+ if (!$form.find('input[name="upload"]').val().length) {
+ if (this.selected_existing().size()) {
+ event.preventDefault();
+ return false;
+ }
+ }
+ $form.find('.well > div').hide().last().after('<span class="fa fa-spin fa-3x fa-refresh"/>');
+
+ var callback = _.uniqueId('func_');
+ this.$('input[name=func]').val(callback);
+ window[callback] = function (attachments, error) {
+ delete window[callback];
+ $form.find('.well > span').remove();
+ $form.find('.well > div').show();
+ _.each(attachments, function (record) {
+ record.src = record.url || '/web/image/' + record.id;
+ record.is_document = !(/gif|jpe|jpg|png/.test(record.mimetype));
+ });
+ if (error || !attachments.length) {
+ self.file_selected(null, error || !attachments.length);
+ }
+ self.images = attachments;
+ for (var i=0; i<attachments.length; i++) {
+ self.file_selected(attachments[i], error);
+ }
+ };
+ },
+ file_selection: function () {
+ var $form = this.$('form');
+ this.$el.addClass('nosave');
+ $form.removeClass('has-error').find('.help-block').empty();
+ this.$('button.filepicker').removeClass('btn-danger btn-success');
+ $form.submit();
+ },
+ file_selected: function (attachment, error) {
+ var $button = this.$('button.filepicker');
+ if (!error) {
+ $button.addClass('btn-success');
+ this.set_image(attachment);
+ } else {
+ this.$('form').addClass('has-error').find('.help-block').text(error);
+ $button.addClass('btn-danger');
+ }
+
+ if (!this.options.select_images) {
+ this.parent.save(); // auto save and close popup
+ }
+ },
+ fetch_existing: function (needle) {
+ var domain = [['res_model', '=', 'ir.ui.view']].concat(this.domain);
+ if (needle && needle.length) {
+ domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]);
+ }
+ return ajax.jsonRpc('/web/dataset/call_kw', 'call', {
+ model: 'ir.attachment',
+ method: 'search_read',
+ args: [],
+ kwargs: {
+ domain: domain,
+ fields: ['name', 'mimetype', 'checksum', 'url', 'type'],
+ order: 'id desc',
+ context: base.get_context()
+ }
+ }).then(this.proxy('fetched_existing'));
+ },
+ fetched_existing: function (records) {
+ this.records = _.uniq(_.filter(records, function (r) {
+ return (r.type === "binary" || r.url && r.url.length > 0);
+ }), function (r) {
+ return (r.url || r.id);
+ });
+ _.each(this.records, function (record) {
+ record.src = record.url || '/web/image/' + record.id;
+ record.is_document = !(/gif|jpe|jpg|png/.test(record.mimetype));
+ });
+ this.display_attachments();
+ },
+ display_attachments: function () {
+ var self = this;
+ var per_screen = this.IMAGES_PER_ROW * this.IMAGES_ROWS;
+ var from = this.page * per_screen;
+ var records = this.records;
+
+ // Create rows of 3 records
+ var rows = _(records).chain()
+ .slice(from, from + per_screen)
+ .groupBy(function (_, index) { return Math.floor(index / self.IMAGES_PER_ROW); })
+ .values()
+ .value();
+
+ this.$('.help-block').empty();
+
+ this.$('.existing-attachments').replaceWith(QWeb.render('web_editor.dialog.image.existing.content', {rows: rows}));
+ this.parent.$('.pager')
+ .find('li.previous a').toggleClass('disabled', (from === 0)).end()
+ .find('li.next a').toggleClass('disabled', (from + per_screen >= records.length));
+
+ this.$el.find('.o_image').each(function () {
+ var $div = $(this);
+ if (/gif|jpe|jpg|png/.test($div.data('mimetype'))) {
+ var $img = $('<img/>').addClass('img img-responsive').attr('src', $div.data('url') || $div.data('src'));
+ $div.addClass('o_webimage').append($img);
+ }
+ });
+ this.selected_existing();
+ },
+ select_existing: function (e) {
+ var $img = $(e.currentTarget);
+ if (!this.options.select_images) {
+ this.$('.o_existing_attachment_cell.o_selected').removeClass("o_selected");
+ $img.closest('.o_existing_attachment_cell').addClass('o_selected');
+ } else {
+ $img.closest('.o_existing_attachment_cell').toggleClass('o_selected');
+ }
+ var attachment = _.find(this.records, function (record) { return record.id === $img.data('id'); });
+ this.push(attachment);
+ this.selected_existing();
+ },
+ selected_existing: function () {
+ var self = this;
+ var $select = this.$('.o_existing_attachment_cell [data-src]').filter(function () {
+ var $img = $(this);
+ return !!_.find(self.images, function (v) {
+ return (v.url === $img.data("src") || ($img.data("url") && v.url === $img.data("url")) || v.id === $img.data("id"));
+ });
+ });
+ return $select;
+ },
+ try_remove: function (e) {
+ var $help_block = this.$('.help-block').empty();
+ var self = this;
+ var $a = $(e.target);
+ var id = parseInt($a.data('id'), 10);
+ var attachment = _.findWhere(this.records, {id: id});
+ var $both = $a.parent().children();
+
+ $both.css({borderWidth: "5px", borderColor: "#f00"});
+
+ return ajax.jsonRpc('/web_editor/attachment/remove', 'call', {'ids': [id]}).then(function (prevented) {
+ if (_.isEmpty(prevented)) {
+ self.records = _.without(self.records, attachment);
+ self.display_attachments();
+ return;
+ }
+ $both.css({borderWidth: "", borderColor: ""});
+ $help_block.replaceWith(QWeb.render('web_editor.dialog.image.existing.error', {
+ views: prevented[id]
+ }));
+ });
+ },
+});
+
+/* 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 (eg: fa fa-refresh)
+ * - parser: regular expression used to select all font in css style sheets
+ */
+var fontIcons = [{'base': 'fa', 'parser': /(?=^|\s)(\.fa-[0-9a-z_-]+::?before)/i}];
+
+var cacheCssSelectors = {};
+var getCssSelectors = function (filter) {
+ var css = [];
+ if (cacheCssSelectors[filter]) {
+ return cacheCssSelectors[filter];
+ }
+ var sheets = document.styleSheets;
+ for(var i = 0; i < sheets.length; i++) {
+ var rules;
+ if (sheets[i].rules) {
+ rules = sheets[i].rules;
+ } else {
+ //try...catch because Firefox not able to enumerate document.styleSheets[].cssRules[] for cross-domain stylesheets.
+ try {
+ rules = sheets[i].cssRules;
+ } catch(e) {
+ console.warn("Can't read the css rules of: " + sheets[i].href, e);
+ continue;
+ }
+ }
+ if (rules) {
+ for(var r = 0; r < rules.length; r++) {
+ var selectorText = rules[r].selectorText;
+ if (selectorText) {
+ selectorText = selectorText.split(/\s*,\s*/);
+ var data = null;
+ for(var s = 0; s < selectorText.length; s++) {
+ var match = selectorText[s].match(filter);
+ if (match) {
+ var clean = match[1].slice(1).replace(/::?before$/, '');
+ if (!data) {
+ data = [match[1], rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''), clean, [clean]];
+ } else {
+ data[0] += (", " + match[1]);
+ data[3].push(clean);
+ }
+ }
+ }
+ if (data) {
+ css.push(data);
+ }
+ }
+ }
+ }
+ }
+ return cacheCssSelectors[filter] = css;
+};
+var computeFonts = _.once(function () {
+ _.each(fontIcons, function (data) {
+ data.cssData = getCssSelectors(data.parser);
+ data.alias = [];
+ data.icons = _.map(data.cssData, function (css) {
+ data.alias.push.apply(data.alias, css[3]);
+ return css[2];
+ });
+ });
+});
+
+rte.Class.include({
+ init: function () {
+ this._super.apply(this, arguments);
+ computeFonts();
+ },
+ onEnableEditableArea: 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();
+ }
+ },
+});
+
+/**
+ * FontIconsDialog widget. Lets users change a font awesome, support all
+ * font awesome loaded in the css files.
+ */
+var fontIconsDialog = Widget.extend({
+ template: 'web_editor.dialog.font-icons',
+ events : _.extend({}, Dialog.prototype.events, {
+ 'click .font-icons-icon': function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.$('#fa-icon').val(e.target.getAttribute('data-id'));
+ $(".font-icons-icon").removeClass("o_selected");
+ $(e.target).addClass("o_selected");
+ },
+ 'dblclick .font-icons-icon': function () {
+ this.getParent().save();
+ },
+ 'keydown.dismiss.bs.modal': function () {},
+ }),
+
+ init: function (parent, media) {
+ this._super.apply(this, arguments);
+ this.parent = parent;
+ this.media = media;
+ computeFonts();
+ },
+ start: function () {
+ return this._super.apply(this, arguments).then(this.proxy('load_data'));
+ },
+ renderElement: function () { // extract list of font (like awesome) from the cheatsheet.
+ this.iconsParser = fontIcons;
+ this.icons = _.flatten(_.map(fontIcons, function (data) { // TODO maybe useless now
+ return data.icons;
+ }));
+ this.alias = _.flatten(_.map(fontIcons, function (data) {
+ return data.alias;
+ }));
+ this._super.apply(this, arguments);
+ },
+ search: function (needle) {
+ var iconsParser = this.iconsParser;
+ if (needle) {
+ iconsParser = [];
+ _.filter(this.iconsParser, function (data) {
+ var cssData = _.filter(data.cssData, function (cssData) {
+ return _.find(cssData[3], function (alias) {
+ return alias.indexOf(needle) !== -1;
+ });
+ });
+ if (cssData.length) {
+ iconsParser.push({
+ base: data.base,
+ cssData: cssData
+ });
+ }
+ });
+ }
+ this.$('div.font-icons-icons').html(
+ QWeb.render('web_editor.dialog.font-icons.icons', {'iconsParser': iconsParser}));
+ },
+ /**
+ * Removes existing FontAwesome classes on the bound element, and sets
+ * all the new ones if necessary.
+ */
+ save: function () {
+ var self = this;
+ var style = this.media.attributes.style ? this.media.attributes.style.value : '';
+ var classes = (this.media.className||"").split(/\s+/);
+ var custom_classes = /^fa(-[1-5]x|spin|rotate-(9|18|27)0|flip-(horizont|vertic)al|fw|border)?$/;
+ var non_fa_classes = _.reject(classes, function (cls) {
+ return self.getFont(cls) || custom_classes.test(cls);
+ });
+ var final_classes = non_fa_classes.concat(this.get_fa_classes());
+ if (this.media.tagName !== "SPAN") {
+ var media = document.createElement('span');
+ $(media).data($(this.media).data());
+ $(this.media).replaceWith(media);
+ this.media = media;
+ style = style.replace(/\s*width:[^;]+/, '');
+ }
+ $(this.media).attr("class", _.compact(final_classes).join(' ')).attr("style", style);
+
+ return this.media;
+ },
+ /**
+ * return the data font object (with base, parser and icons) or null
+ */
+ getFont: function (classNames) {
+ if (!(classNames instanceof Array)) {
+ classNames = (classNames||"").split(/\s+/);
+ }
+ var fontIcon, cssData;
+ for (var k=0; k<this.iconsParser.length; k++) {
+ fontIcon = this.iconsParser[k];
+ for (var s=0; s<fontIcon.cssData.length; s++) {
+ cssData = fontIcon.cssData[s];
+ if (_.intersection(classNames, cssData[3]).length) {
+ return {
+ 'base': fontIcon.base,
+ 'parser': fontIcon.parser,
+ 'icons': fontIcon.icons,
+ 'font': cssData[2]
+ };
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Looks up the various FontAwesome classes on the bound element and
+ * sets the corresponding template/form elements to the right state.
+ * If multiple classes of the same category are present on an element
+ * (e.g. fa-lg and fa-3x) the last one occurring will be selected,
+ * which may not match the visual look of the element.
+ */
+ load_data: function () {
+ var classes = (this.media&&this.media.className||"").split(/\s+/);
+ for (var i = 0; i < classes.length; i++) {
+ var cls = classes[i];
+ switch (cls) {
+ case 'fa-1x':case 'fa-2x':case 'fa-3x':case 'fa-4x':case 'fa-5x':
+ // size classes
+ this.$('#fa-size').val(cls);
+ continue;
+ case 'fa-spin':
+ case 'fa-rotate-90':case 'fa-rotate-180':case 'fa-rotate-270':
+ case 'fa-flip-horizontal':case 'fa-rotate-vertical':
+ this.$('#fa-rotation').val(cls);
+ continue;
+ case 'fa-fw':
+ continue;
+ case 'fa-border':
+ this.$('#fa-border').prop('checked', true);
+ continue;
+ case '': continue;
+ default:
+ $(".font-icons-icon").removeClass("o_selected").filter("[data-alias*=',"+cls+",']").addClass("o_selected");
+ if (this.alias.indexOf(cls) !== -1) {
+ this.$('#fa-icon').val(cls);
+ }
+ }
+ }
+ },
+ /**
+ * Serializes the dialog to an array of FontAwesome classes. Includes the base ``fa``.
+ */
+ get_fa_classes: function () {
+ var font = this.getFont(this.$('#fa-icon').val());
+ return [
+ font ? font.base : 'fa',
+ font ? font.font : "",
+ this.$('#fa-size').val(),
+ this.$('#fa-rotation').val(),
+ this.$('#fa-border').prop('checked') ? 'fa-border' : ''
+ ];
+ },
+ clear: function () {
+ this.media.className = this.media.className.replace(/(^|\s)(fa(\s|$)|fa-[^\s]*)/g, ' ');
+ },
+});
+
+
+function createVideoNode(url, options) {
+ options = options || {};
+
+ // video url patterns(youtube, instagram, vimeo, dailymotion, youku)
+ var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
+ var ytMatch = url.match(ytRegExp);
+
+ var igRegExp = /\/\/instagram.com\/p\/(.[a-zA-Z0-9]*)/;
+ var igMatch = url.match(igRegExp);
+
+ var vRegExp = /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/;
+ var vMatch = url.match(vRegExp);
+
+ var vimRegExp = /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/;
+ var vimMatch = url.match(vimRegExp);
+
+ var dmRegExp = /.+dailymotion.com\/(video|hub)\/([^_]+)[^#]*(#video=([^_&]+))?/;
+ var dmMatch = url.match(dmRegExp);
+
+ var youkuRegExp = /\/\/v\.youku\.com\/v_show\/id_(\w+)\.html/;
+ var youkuMatch = url.match(youkuRegExp);
+
+ var $video = $('<iframe>');
+ if (ytMatch && ytMatch[1].length === 11) {
+ var youtubeId = ytMatch[1];
+ $video = $('<iframe>', {
+ src: '//www.youtube.com/embed/' + youtubeId,
+ width: '640',
+ height: '360'
+ });
+ } else if (igMatch && igMatch[0].length) {
+ $video = $('<iframe>', {
+ src: igMatch[0] + '/embed/',
+ width: '612',
+ height: '710',
+ scrolling: 'no',
+ allowtransparency: 'true'
+ });
+ } else if (vMatch && vMatch[0].length) {
+ $video = $('<iframe>', {
+ src: vMatch[0] + '/embed/simple',
+ width: '600',
+ height: '600',
+ class: 'vine-embed'
+ });
+ } else if (vimMatch && vimMatch[3].length) {
+ $video = $('<iframe>', {
+ src: '//player.vimeo.com/video/' + vimMatch[3],
+ width: '640',
+ height: '360'
+ });
+ } else if (dmMatch && dmMatch[2].length) {
+ $video = $('<iframe>', {
+ src: '//www.dailymotion.com/embed/video/' + dmMatch[2],
+ width: '640',
+ height: '360'
+ });
+ } else if (youkuMatch && youkuMatch[1].length) {
+ $video = $('<iframe>', {
+ height: '498',
+ width: '510',
+ src: '//player.youku.com/embed/' + youkuMatch[1]
+ });
+ } else {
+ // this is not a known video link. Now what, Cat? Now what?
+ $video = $('<iframe>', {
+ width: '640',
+ height: '360',
+ src: url
+ });
+ }
+
+ if (options.autoplay) {
+ $video.attr("src", $video.attr("src") + "?autoplay=1");
+ }
+
+ $video.attr('frameborder', 0);
+
+ return $video;
+}
+
+/**
+ * VideoDialog widget. Lets users change a video, support all summernote
+ * video, and embled iframe
+ */
+var VideoDialog = Widget.extend({
+ template: 'web_editor.dialog.video',
+ events : _.extend({}, Dialog.prototype.events, {
+ 'click input#urlvideo ~ button': 'get_video',
+ 'click input#embedvideo ~ button': 'get_embed_video',
+ 'change input#autoplay': 'get_video',
+ 'change input#urlvideo': 'change_input',
+ 'keyup input#urlvideo': 'change_input',
+ 'change input#embedvideo': 'change_input',
+ 'keyup input#embedvideo': 'change_input',
+ 'keydown.dismiss.bs.modal': function () {},
+ }),
+ init: function (parent, media) {
+ this._super.apply(this, arguments);
+ this.parent = parent;
+ this.media = media;
+ },
+ start: function () {
+ this.$preview = this.$('.preview-container').detach();
+ this.$iframe = this.$("iframe");
+ var $media = $(this.media);
+ if ($media.hasClass("media_iframe_video")) {
+ var src = $media.data('src');
+ this.$("input#urlvideo").val(src);
+ this.$("input#autoplay").prop("checked", (src || "").indexOf("autoplay") >= 0);
+ this.get_video();
+ }
+ return this._super.apply(this, arguments);
+ },
+ change_input: function (e) {
+ var $input = $(e.target);
+ var $button = $input.parent().find("button");
+ if ($input.val() === "") {
+ $button.addClass("btn-default").removeClass("btn-primary");
+ } else {
+ $button.removeClass("btn-default").addClass("btn-primary");
+ }
+ },
+ get_embed_video: function (event) {
+ event.preventDefault();
+ var embedvideo = this.$("input#embedvideo").val().match(/src=["']?([^"']+)["' ]?/);
+ if (embedvideo) {
+ this.$("input#urlvideo").val(embedvideo[1]);
+ this.get_video(event);
+ }
+ return false;
+ },
+ get_video: function (event) {
+ if (event) event.preventDefault();
+ var $video = createVideoNode(this.$("input#urlvideo").val(), {autoplay: this.$("input#autoplay").is(":checked")});
+ this.$iframe.replaceWith($video);
+ this.$iframe = $video;
+ return false;
+ },
+ save: function () {
+ var video_id = this.$("#video_id").val();
+ if (!video_id) {
+ this.$("button.btn-primary").click();
+ video_id = this.$("#video_id").val();
+ }
+ var $iframe = $(
+ '<div class="media_iframe_video" data-src="'+this.$iframe.attr("src")+'">'+
+ '<div class="css_editable_mode_display">&nbsp;</div>'+
+ '<div class="media_iframe_video_size" contentEditable="false">&nbsp;</div>'+
+ '<iframe src="'+this.$iframe.attr("src")+'" frameborder="0" allowfullscreen="allowfullscreen" contentEditable="false"></iframe>'+
+ '</div>');
+ $(this.media).replaceWith($iframe);
+ this.media = $iframe[0];
+
+ return this.media;
+ },
+ clear: function () {
+ if (this.media.dataset.src) {
+ try {
+ delete this.media.dataset.src;
+ } catch(e) {
+ this.media.dataset.src = undefined;
+ }
+ }
+ this.media.className = this.media.className.replace(/(^|\s)media_iframe_video(\s|$)/g, ' ');
+ },
+});
+
+/**
+ * The Link Dialog allows to customize link content and style.
+ */
+var LinkDialog = Dialog.extend({
+ template: 'web_editor.dialog.link',
+ events: _.extend({}, Dialog.prototype.events, {
+ 'change :input.url-source': 'changed',
+ 'keyup :input.url': 'onkeyup',
+ 'keyup :input': 'preview',
+ 'click button.remove': 'remove_link',
+ 'change input#o_link_dialog_label_input': function (e) {
+ this.text = $(e.target).val();
+ },
+ 'change .link-style': function (e) {
+ this.preview();
+ },
+ }),
+ init: function (parent, options, editable, linkInfo) {
+ this._super(parent, _.extend({}, {
+ title: _t("Link to")
+ }, options || {}));
+ this.editable = editable;
+ this.data = linkInfo || {};
+
+ this.data.className = "";
+ if (this.data.range) {
+ this.data.iniClassName = $(this.data.range.sc).filter("a").attr("class") || "";
+ this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
+
+ var is_link = this.data.range.isOnAnchor();
+ var r = this.data.range;
+
+ var sc = r.sc;
+ var so = r.so;
+ var ec = r.ec;
+ var eo = r.eo;
+
+ var nodes;
+ if (!is_link) {
+ if (sc.tagName) {
+ sc = dom.firstChild(so ? sc.childNodes[so] : sc);
+ so = 0;
+ } else if (so !== sc.textContent.length) {
+ if (sc === ec) {
+ ec = sc = sc.splitText(so);
+ eo -= so;
+ } else {
+ sc = sc.splitText(so);
+ }
+ so = 0;
+ }
+ if (ec.tagName) {
+ ec = dom.lastChild(eo ? ec.childNodes[eo-1] : ec);
+ eo = ec.textContent.length;
+ } else if (eo !== ec.textContent.length) {
+ ec.splitText(eo);
+ }
+
+ nodes = dom.listBetween(sc, ec);
+
+ // browsers can't target a picture or void node
+ if (dom.isVoid(sc) || dom.isImg(sc)) {
+ so = dom.listPrev(sc).length-1;
+ sc = sc.parentNode;
+ }
+ if (dom.isBR(ec)) {
+ eo = dom.listPrev(ec).length-1;
+ ec = ec.parentNode;
+ } else if (dom.isVoid(ec) || dom.isImg(sc)) {
+ eo = dom.listPrev(ec).length;
+ ec = ec.parentNode;
+ }
+
+ this.data.range = range.create(sc, so, ec, eo);
+ this.data.range.select();
+ } else {
+ nodes = dom.ancestor(sc, dom.isAnchor).childNodes;
+ }
+
+ if (dom.isImg(sc) && nodes.indexOf(sc) === -1) {
+ nodes.push(sc);
+ }
+ if (nodes.length > 1 || dom.ancestor(nodes[0], dom.isImg)) {
+ var text = "";
+ this.data.images = [];
+ for (var i=0; i<nodes.length; i++) {
+ if (dom.ancestor(nodes[i], dom.isImg)) {
+ this.data.images.push(dom.ancestor(nodes[i], dom.isImg));
+ text += '[IMG]';
+ } else if (!is_link && i===0) {
+ text += nodes[i].textContent.slice(so, Infinity);
+ } else if (!is_link && i===nodes.length-1) {
+ text += nodes[i].textContent.slice(0, eo);
+ } else {
+ text += nodes[i].textContent;
+ }
+ }
+ this.data.text = text;
+ }
+ }
+
+ this.data.text = this.data.text.replace(/[ \t\r\n]+/g, ' ');
+ },
+ start: function () {
+ this.bind_data();
+ this.$('input.url-source:eq(1)').closest('.list-group-item').addClass('active');
+ this.$('#o_link_dialog_label_input').focus();
+ return this._super.apply(this, arguments);
+ },
+ get_data: function (test) {
+ var self = this;
+ var def = new $.Deferred();
+ var $e = this.$('.active input.url-source');
+ if (!$e.length) {
+ $e = this.$('input.url-source:first');
+ }
+ var val = $e.val();
+ var label = this.$('#o_link_dialog_label_input').val() || val;
+
+ if (label && this.data.images) {
+ for(var i=0; i<this.data.images.length; i++) {
+ label = label.replace(/</, "&lt;").replace(/>/, "&gt;").replace(/\[IMG\]/, this.data.images[i].outerHTML);
+ }
+ }
+
+ if (!test && (!val || !$e[0].checkValidity())) {
+ // FIXME: error message
+ $e.closest('.form-group').addClass('has-error');
+ $e.focus();
+ def.reject();
+ }
+
+ var style = this.$("input[name='link-style-type']:checked").val() || '';
+ var size = this.$("select.link-style").val() || '';
+ var classes = (this.data.className || "") + (style && style.length ? " btn " : "") + style + " " + size;
+ var isNewWindow = this.$('input.window-new').prop('checked');
+
+ if ($e.hasClass('email-address') && $e.val().indexOf("@") !== -1) {
+ self.get_data_buy_mail(def, $e, isNewWindow, label, classes, test);
+ } else {
+ self.get_data_buy_url(def, $e, isNewWindow, label, classes, test);
+ }
+ return def;
+ },
+ get_data_buy_mail: function (def, $e, isNewWindow, label, classes, test) {
+ var val = $e.val();
+ def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes);
+ },
+ get_data_buy_url: function (def, $e, isNewWindow, label, classes, test) {
+ def.resolve($e.val(), isNewWindow, label, classes);
+ },
+ save: function () {
+ var self = this;
+ var _super = this._super.bind(this);
+ return this.get_data().then(function (url, new_window, label, classes) {
+ self.data.url = url;
+ self.data.isNewWindow = new_window;
+ self.data.text = label;
+ self.data.className = classes.replace(/\s+/gi, ' ').replace(/^\s+|\s+$/gi, '');
+ if (classes.replace(/(^|[ ])(btn-default|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi, ' ')) {
+ self.data.style = {'background-color': '', 'color': ''};
+ }
+ self.final_data = self.data;
+ }).then(_super);
+ },
+ bind_data: function () {
+ var href = this.data.url;
+ var new_window = this.data.isNewWindow;
+ var text = this.data.text;
+ var classes = this.data.iniClassName;
+
+ this.$('input#o_link_dialog_label_input').val(text);
+ this.$('input.window-new').prop('checked', new_window);
+ this.$('input.link-style').prop('checked', false).first().prop("checked", true);
+
+ if (classes) {
+ this.$('input.link-style, select.link-style > option').each(function () {
+ var $option = $(this);
+ if ($option.val() && classes.indexOf($option.val()) >= 0) {
+ if ($option.is("input")) {
+ $option.prop("checked", true);
+ } else {
+ $option.parent().val($option.val());
+ }
+ }
+ });
+ }
+
+ if (href) {
+ var match = /mailto:(.+)/.exec(href);
+ if (match) {
+ this.$('input.email-address').val(match = /mailto:(.+)/.exec(href) ? match[1] : '');
+ } else {
+ this.$('input.url').val(href);
+ }
+ }
+ this.preview();
+ },
+ changed: function (e) {
+ $(e.target).closest('.list-group-item')
+ .addClass('active')
+ .siblings().removeClass('active')
+ .addBack().removeClass('has-error');
+ this.preview();
+ },
+ onkeyup: function (e) {
+ var $e = $(e.target);
+ var is_link = $e.val().indexOf("@") === -1;
+ this.$('input.window-new').closest("div").toggle(is_link);
+ this.preview();
+ },
+ preview: function () {
+ var $preview = this.$("#link-preview");
+ this.get_data(true).then(function (url, new_window, label, classes) {
+ $preview.attr("target", new_window ? '_blank' : "")
+ .attr("href", url && url.length ? url : "#")
+ .html((label && label.length ? label : url))
+ .attr("class", classes.replace(/pull-\w+/, '') + " o_btn_preview");
+ });
+ }
+});
+
+return {
+ Dialog: Dialog,
+ alt: alt,
+ MediaDialog: MediaDialog,
+ LinkDialog: LinkDialog,
+ getCssSelectors: getCssSelectors,
+ fontIcons: fontIcons,
+};
+});