diff options
Diffstat (limited to 'addons/web_editor/static/src')
| -rw-r--r-- | addons/web_editor/static/src/js/ace.js | 416 | ||||
| -rw-r--r-- | addons/web_editor/static/src/less/web_editor.ui.components.less | 7 | ||||
| -rw-r--r-- | addons/web_editor/static/src/xml/ace.xml | 25 |
3 files changed, 342 insertions, 106 deletions
diff --git a/addons/web_editor/static/src/js/ace.js b/addons/web_editor/static/src/js/ace.js index bbcc4fa4f74..7367c33eaa6 100644 --- a/addons/web_editor/static/src/js/ace.js +++ b/addons/web_editor/static/src/js/ace.js @@ -13,57 +13,103 @@ 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 { - isValid: false, - error: { - line: parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), - message: error[0].innerHTML - } - }; + 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 { - isValid: false, - error: { - line: xmlDocIE.parseError.line, - message: xmlDocIE.parseError.reason - } - }; + return _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason); } } - - return { - isValid: true - }; + 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: { - 'change #ace-view-list': function () { - this.displayView(this.selectedViewId()); + "click .o_ace_type_switcher_choice": function (e) { + e.preventDefault(); + this.switchType($(e.target).data("type")); + }, + 'change .oe_view_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', }, @@ -74,7 +120,7 @@ var ViewEditor = Widget.extend({ * @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 - * - initialViewID: a specific view ID to load on start (otherwise the main view ID + * - 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. */ @@ -82,10 +128,22 @@ var ViewEditor = Widget.extend({ 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"; - this.options = _.defaults({}, options, {position: 'right'}); - this.views = {}; - this.buffers = {}; + // 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. @@ -95,6 +153,7 @@ var ViewEditor = Widget.extend({ 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') ); }); @@ -106,10 +165,22 @@ var ViewEditor = Widget.extend({ * @return a deferred which is resolved when the widget DOM content is fully loaded. */ start: function () { - this.$viewEditor = this.$('#ace-view-editor'); + this.$viewEditor = this.$("#ace-view-editor"); this.$editor = this.$(".ace_editor"); - this.$viewList = this.$('#ace-view-list'); - this.$viewID = this.$('#ace-view-id'); + + 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"); @@ -120,10 +191,32 @@ var ViewEditor = Widget.extend({ var debounceStoreEditorWidth = _.debounce(storeEditorWidth, 500); this._updateViewSelectDOM(); - this.displayView( - this.options.initialViewID - || (typeof this.viewKey === "number" ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id) - ); + + 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') { @@ -186,14 +279,29 @@ var ViewEditor = Widget.extend({ * @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 ajax.jsonRpc("/web_editor/get_assets_editor_resources", "call", { key: this.viewKey, - bundles: this.options.includeBundles - }).then((function (views) { + 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. - this.views = _.indexBy(_.filter(views, function (view) { + _.extend(this.views, _.indexBy(_.filter(views, function (view) { return view.active; - }), "id"); + }), "id")); // Initialize a 0 level for each view and assign them an array containing their children. var self = this; @@ -225,63 +333,133 @@ var ViewEditor = Widget.extend({ _.each(roots, function (root) { visit(root, 0); }); - }).bind(this)); + } + + 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 + * 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.selectedViewId(); + var currentId = this.selectedResource(); var self = this; - this.$viewList.empty(); + this.$lists.xml.empty(); _.each(this.sorted_views, function (view) { - self.$viewList.append($("<option/>", { + self.$lists.xml.append($("<option/>", { value: view.id, text: _.str.sprintf("%s%s", _.str.repeat("- ", view.level), view.name), - selected: currentId === view.id + selected: currentId === view.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) { + $optgroup.append($("<option/>", { + value: lessInfo.url, + text: lessInfo.url + (lessInfo.customized ? _.str.sprintf(" (%s)", _t("customized")) : ""), + selected: currentId === lessInfo.url, + })); + }); + }); + }, + /** + * 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 selectedViewId method returns the currently selected view id. - * @return the currently selected view id. + * 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) */ - selectedViewId: function () { - return parseInt(this.$viewList.val(), 10); + selectedResource: function () { + return this.$lists[this.currentType].val(); }, /** - * The displayView method forces the view identified by its ID to be displayed in the editor. - * The method will update the view select DOM element as well. - * @param viewID: the ID of the view to display. + * 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") */ - displayView: function (viewID) { - var editingSession = this.buffers[viewID]; + displayResource: function (resID, type) { + if (type) this.switchType(type); + + var editingSession = this.editingSessions[this.currentType][resID]; if (!editingSession) { - editingSession = this._buildEditingSession(viewID); + editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID); } this.aceEditor.setSession(editingSession); - this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[viewID].xml_id)); - if (viewID !== this.selectedViewId()) { - this.$viewList.val(viewID); + 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].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 ajax.jsonRpc("/web_editor/reset_less", "call", { + url: resID, + bundle_xmlid: this.less[resID].bundle_xmlid, + }); } }, /** - * The private _buildEditingSession method initialize a text editor for the specified view. - * @param viewID: the ID of the view to display. - * @return an ace.EditSession object linked to the specified viewID. + * 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(viewID) { + _buildEditingSession: function(resID, type) { var self = this; - var editingSession = this.buffers[viewID] = new window.ace.EditSession(this.views[viewID].arch); + type = type || this.currentType; + var editingSession = new window.ace.EditSession(this.resources[type][resID].arch); editingSession.setUseWorker(false); - editingSession.setMode("ace/mode/xml"); + editingSession.setMode("ace/mode/" + (type || this.currentType)); editingSession.setUndoManager(new window.ace.UndoManager()); editingSession.on("change", function () { _.defer(function () { - self._toggleDirtyInfo(viewID); + self._toggleDirtyInfo(resID); self._showErrorLine(); }); }); @@ -289,18 +467,21 @@ var ViewEditor = Widget.extend({ }, /** * The private _toggleDirtyInfo method update the select option DOM element associated with - * a particular viewID to indicate if the option is dirty or not. - * @param viewID: the view id whose option has to be updated + * 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 (viewID, isDirty) { - if (!viewID || !this.buffers[viewID]) return; + _toggleDirtyInfo: function (resID, type, isDirty) { + type = type || this.currentType; + + if (!resID || !this.editingSessions[type][resID]) return; - var $option = this.$viewList.find("[value=" + viewID + "]"); + var $option = this.$lists[type].find("[value='" + resID + "']"); var optionName = $option.text(); var dirtyMarker = " (" + _t("unsaved changes") + ")"; if (isDirty === undefined) { - isDirty = this.buffers[viewID].getUndoManager().hasUndo(); + isDirty = this.editingSessions[type][resID].getUndoManager().hasUndo(); } if (isDirty && optionName.indexOf(dirtyMarker) < 0) { $option.text(optionName + dirtyMarker); @@ -310,21 +491,15 @@ var ViewEditor = Widget.extend({ }, /** * The formatResource method formats the current resource being vizualized. + * TODO formatting LESS files is not supported yet. */ formatResource: function () { - this._formatXML(); - }, - /** - * The private _formatXML method formats the current resource assuming it is an XML document. - * If the xml is not valid, it shows the error instead. - */ - _formatXML: function () { - var xml = this.aceEditor.getValue(); - var check = checkXML(xml); + var res = this.aceEditor.getValue(); + var check = (this.currentType === "xml" ? checkXML : checkLESS)(res); if (check.isValid) { - this.aceEditor.setValue(formatXML(xml)); + this.aceEditor.setValue((this.currentType === "xml" ? formatXML : formatLESS)(res)); } else { - this._showErrorLine(check.error.line, check.error.message, this.selectedViewId()); + this._showErrorLine(check.error.line, check.error.message, this.selectedResource()); } }, /** @@ -333,35 +508,47 @@ var ViewEditor = Widget.extend({ * @return a deferred whose status indicates if the save is finished or if an error occured. */ saveResources: function () { - var dirtyBuffers = _.pick(this.buffers, function (session) { - return session.getUndoManager().hasUndo(); - }); - var toSave = _.map(dirtyBuffers, function (editingSession, viewID) { - return { - id: parseInt(viewID, 10), - text: editingSession.getValue(), - }; - }); + 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.length ; i++) { - var check = checkXML(toSave[i].text); - if (!check.isValid) { - this._showErrorLine(check.error.line, check.error.message, toSave[i].id); - return $.Deferred().reject(toSave[i]); + 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($, _.map(toSave, this._saveView.bind(this))).fail(function (session, error) { + 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 XML you are saving is well-formed.") + _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. @@ -377,7 +564,29 @@ var ViewEditor = Widget.extend({ method: 'write', args: [[session.id], {arch: session.text}, _.extend(base.get_context(), {lang: null})], }).then(function () { - self._toggleDirtyInfo(session.id, false); + 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; + ajax.jsonRpc("/web_editor/save_less", "call", { + 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); @@ -391,9 +600,10 @@ var ViewEditor = Widget.extend({ * 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 viewID: the id of the view whose line is to highlight + * @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, viewID) { + _showErrorLine: function (line, message, resID, type) { if (line === undefined || line <= 0) { if (this.$errorLine) { this.$errorLine.removeClass("o_error"); @@ -405,7 +615,9 @@ var ViewEditor = Widget.extend({ return; } - if (this.selectedViewId() === viewID) { + if (type) this.switchType(type); + + if (this.selectedResource() === resID) { __showErrorLine.call(this, line); } else { var onChangeSession = (function () { @@ -413,7 +625,7 @@ var ViewEditor = Widget.extend({ _.delay(__showErrorLine.bind(this, line), 400); }).bind(this); this.aceEditor.on('changeSession', onChangeSession); - this.displayView(viewID); + this.displayResource(resID, this.currentType); } function __showErrorLine(line) { diff --git a/addons/web_editor/static/src/less/web_editor.ui.components.less b/addons/web_editor/static/src/less/web_editor.ui.components.less index 9d40da2a9f3..709e9b03b2e 100644 --- a/addons/web_editor/static/src/less/web_editor.ui.components.less +++ b/addons/web_editor/static/src/less/web_editor.ui.components.less @@ -135,11 +135,16 @@ .o-justify-content(space-between); padding: @grid-gutter-width/4; + > .o_ace_type_switcher > button::after { + .o-caret-down(); + margin-left: 4px; + } + > * { .o-flex(0, 0, auto); margin: 0 @grid-gutter-width/4; - &.oe_include_bundles { + &.o_include_option { .o-flex-display(); .o-align-items(center); font-size: 11px; diff --git a/addons/web_editor/static/src/xml/ace.xml b/addons/web_editor/static/src/xml/ace.xml index 3451418a94b..5974003f443 100644 --- a/addons/web_editor/static/src/xml/ace.xml +++ b/addons/web_editor/static/src/xml/ace.xml @@ -4,20 +4,39 @@ <t t-name="web_editor.ace_view_editor"> <div class="o_ace_view_editor"> <div class="form-inline o_ace_view_editor_title"> + <div class="btn-group o_ace_type_switcher"> + <button type="button" class="btn btn-sm btn-info dropdown-toggle" data-toggle="dropdown">XML (HTML)</button> + <ul class="dropdown-menu" role="menu"> + <li><a href="#" class="o_ace_type_switcher_choice" data-type="xml">XML (HTML)</a></li> + <li><a href="#" class="o_ace_type_switcher_choice" data-type="less">LESS (CSS)</a></li> + </ul> + </div> <select id="ace-view-list" class="form-control oe_view_list"/> - <label class="oe_include_bundles"> + <select id="ace-less-list" class="form-control oe_view_list hidden"/> + <label class="o_include_option oe_include_bundles"> <div class="o_checkbox"> <input type="checkbox" class="js_include_bundles"/><span/> </div> Include Asset Bundles </label> + <label class="o_include_option o_include_all_less hidden"> + <div class="o_checkbox"> + <input type="checkbox" class="js_include_all_less" t-att-checked="widget.options.includeAllLess ? 'checked' : undefined"/><span/> + </div> + Include All LESS Files + </label> <div class="o_button_section"> <button data-action="save" type="submit" class="btn btn-sm btn-primary">Save</button> - <button data-action="format" type="button" class="btn btn-sm btn-default">Format</button> <button data-action="close" type="button" class="btn btn-sm btn-default">Close</button> </div> </div> - <div id="ace-view-id"/> + <div id="ace-view-id"> + <span/> + <div class="pull-right"> + <button data-action="reset" type="button" class="btn btn-xs btn-danger"><i class="fa fa-undo"/> Reset</button> + <button data-action="format" type="button" class="btn btn-xs btn-link">Format</button> + </div> + </div> <div id="ace-view-editor"/> </div> </t> |
