From 198673bf35f14e3a797d2ff09208a289e12bec4e Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Wed, 7 Dec 2016 14:41:40 +0100 Subject: [PATCH] [ADD] web_editor, website, web: new LESS editor The old "HTML" editor now handles LESS edition. The editor has now a button to switch between XML and LESS edition. By default, it only displays the odoo files which only appear in the assets_frontend bundle and in debug mode, it allows to see all the files that influenced the page design. The new editor has a new set of options which allows to use it as a xml editor only or a less editor only if needed. Also: make the "include bundles" option appear only in debug mode. Technical details: - When saving a modified LESS file, an attachment is created with the full modified content and an extension view xpaths the bundle where the original file was added to link this attachment instead. This relies on the fact that qweb, when generating assets bundle, replaces missing files by the "datas" contents of the ir.attachment's which have an "url" field equal to the urls it is searching. - If a LESS error is introduced, the recent changes in assets bundle generation (see c248674a5c615c9db113357eec9d9adaf50b888e) will not break the design but only open a dialog on page loading, with the LESS error. The user can then go to the LESS editor and reset its changes. This feature code has been started with original work of @pga-odoo --- addons/web/static/lib/ace/mode-less.js | 206 +++++++++ addons/web_editor/controllers/main.py | 203 ++++++++- addons/web_editor/static/src/js/ace.js | 416 +++++++++++++----- .../src/less/web_editor.ui.components.less | 7 +- addons/web_editor/static/src/xml/ace.xml | 25 +- addons/website/static/src/js/website.ace.js | 25 +- .../views/website_navbar_templates.xml | 2 +- 7 files changed, 764 insertions(+), 120 deletions(-) create mode 100644 addons/web/static/lib/ace/mode-less.js diff --git a/addons/web/static/lib/ace/mode-less.js b/addons/web/static/lib/ace/mode-less.js new file mode 100644 index 00000000000..8ee94129b2b --- /dev/null +++ b/addons/web/static/lib/ace/mode-less.js @@ -0,0 +1,206 @@ +define("ace/mode/less", ["require", "exports", "module", "ace/lib/oop", "ace/mode/text", "ace/tokenizer", "ace/mode/less_highlight_rules", "ace/mode/matching_brace_outdent", "ace/mode/folding/cstyle"], function(e, t) { + var r = e("../lib/oop"), + o = e("./text").Mode, + n = e("../tokenizer").Tokenizer, + i = e("./less_highlight_rules").LessHighlightRules, + a = e("./matching_brace_outdent").MatchingBraceOutdent, + l = e("./folding/cstyle").FoldMode, + s = function() { + this.$tokenizer = new n((new i).getRules(), "i"), this.$outdent = new a, this.foldingRules = new l + }; + r.inherits(s, o), + function() { + this.getNextLineIndent = function(e, t, r) { + var o = this.$getIndent(t), + n = this.$tokenizer.getLineTokens(t, e).tokens; + if (n.length && "comment" == n[n.length - 1].type) return o; + var i = t.match(/^.*\{\s*$/); + return i && (o += r), o + }, this.checkOutdent = function(e, t, r) { + return this.$outdent.checkOutdent(t, r) + }, this.autoOutdent = function(e, t, r) { + this.$outdent.autoOutdent(t, r) + } + }.call(s.prototype), t.Mode = s +}), define("ace/mode/less_highlight_rules", ["require", "exports", "module", "ace/lib/oop", "ace/lib/lang", "ace/mode/text_highlight_rules"], function(e, t) { + var r = e("../lib/oop"), + o = e("../lib/lang"), + n = e("./text_highlight_rules").TextHighlightRules, + i = function() { + var e = o.arrayToMap(function() { + for (var e = "-webkit-|-moz-|-o-|-ms-|-svg-|-pie-|-khtml-".split("|"), t = "appearance|background-clip|background-inline-policy|background-origin|background-size|binding|border-bottom-colors|border-left-colors|border-right-colors|border-top-colors|border-end|border-end-color|border-end-style|border-end-width|border-image|border-start|border-start-color|border-start-style|border-start-width|box-align|box-direction|box-flex|box-flexgroup|box-ordinal-group|box-orient|box-pack|box-sizing|column-count|column-gap|column-width|column-rule|column-rule-width|column-rule-style|column-rule-color|float-edge|font-feature-settings|font-language-override|force-broken-image-icon|image-region|margin-end|margin-start|opacity|outline|outline-color|outline-offset|outline-radius|outline-radius-bottomleft|outline-radius-bottomright|outline-radius-topleft|outline-radius-topright|outline-style|outline-width|padding-end|padding-start|stack-sizing|tab-size|text-blink|text-decoration-color|text-decoration-line|text-decoration-style|transform|transform-origin|transition|transition-delay|transition-duration|transition-property|transition-timing-function|user-focus|user-input|user-modify|user-select|window-shadow|border-radius".split("|"), r = "azimuth|background-attachment|background-color|background-image|background-position|background-repeat|background|border-bottom-color|border-bottom-style|border-bottom-width|border-bottom|border-collapse|border-color|border-left-color|border-left-style|border-left-width|border-left|border-right-color|border-right-style|border-right-width|border-right|border-spacing|border-style|border-top-color|border-top-style|border-top-width|border-top|border-width|border|bottom|box-sizing|caption-side|clear|clip|color|content|counter-increment|counter-reset|cue-after|cue-before|cue|cursor|direction|display|elevation|empty-cells|float|font-family|font-size-adjust|font-size|font-stretch|font-style|font-variant|font-weight|font|height|left|letter-spacing|line-height|list-style-image|list-style-position|list-style-type|list-style|margin-bottom|margin-left|margin-right|margin-top|marker-offset|margin|marks|max-height|max-width|min-height|min-width|opacity|orphans|outline-color|outline-style|outline-width|outline|overflow|overflow-x|overflow-y|padding-bottom|padding-left|padding-right|padding-top|padding|page-break-after|page-break-before|page-break-inside|page|pause-after|pause-before|pause|pitch-range|pitch|play-during|position|quotes|richness|right|size|speak-header|speak-numeral|speak-punctuation|speech-rate|speak|stress|table-layout|text-align|text-decoration|text-indent|text-shadow|text-transform|top|unicode-bidi|vertical-align|visibility|voice-family|volume|white-space|widows|width|word-spacing|z-index".split("|"), o = [], n = 0, i = e.length; i > n; n++) Array.prototype.push.apply(o, (e[n] + t.join("|" + e[n])).split("|")); + return Array.prototype.push.apply(o, t), Array.prototype.push.apply(o, r), o + }()), + t = o.arrayToMap("hsl|hsla|rgb|rgba|url|attr|counter|counters|lighten|darken|saturate|desaturate|fadein|fadeout|fade|spin|mix|hue|saturation|lightness|alpha|round|ceil|floor|percentage|color|iscolor|isnumber|isstring|iskeyword|isurl|ispixel|ispercentage|isem".split("|")), + r = o.arrayToMap("absolute|all-scroll|always|armenian|auto|baseline|below|bidi-override|block|bold|bolder|border-box|both|bottom|break-all|break-word|capitalize|center|char|circle|cjk-ideographic|col-resize|collapse|content-box|crosshair|dashed|decimal-leading-zero|decimal|default|disabled|disc|distribute-all-lines|distribute-letter|distribute-space|distribute|dotted|double|e-resize|ellipsis|fixed|georgian|groove|hand|hebrew|help|hidden|hiragana-iroha|hiragana|horizontal|ideograph-alpha|ideograph-numeric|ideograph-parenthesis|ideograph-space|inactive|inherit|inline-block|inline|inset|inside|inter-ideograph|inter-word|italic|justify|katakana-iroha|katakana|keep-all|left|lighter|line-edge|line-through|line|list-item|loose|lower-alpha|lower-greek|lower-latin|lower-roman|lowercase|lr-tb|ltr|medium|middle|move|n-resize|ne-resize|newspaper|no-drop|no-repeat|nw-resize|none|normal|not-allowed|nowrap|oblique|outset|outside|overline|pointer|progress|relative|repeat-x|repeat-y|repeat|right|ridge|row-resize|rtl|s-resize|scroll|se-resize|separate|small-caps|solid|square|static|strict|super|sw-resize|table-footer-group|table-header-group|tb-rl|text-bottom|text-top|text|thick|thin|top|transparent|underline|upper-alpha|upper-latin|upper-roman|uppercase|vertical-ideographic|vertical-text|visible|w-resize|wait|whitespace|zero".split("|")), + n = o.arrayToMap("aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow".split("|")), + i = o.arrayToMap("@mixin|@extend|@include|@import|@media|@debug|@warn|@if|@for|@each|@while|@else|@font-face|@-webkit-keyframes|if|and|!default|module|def|end|declare|when|not|and".split("|")), + a = o.arrayToMap("a|abbr|acronym|address|applet|area|article|aside|audio|b|base|basefont|bdo|big|blockquote|body|br|button|canvas|caption|center|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|dir|div|dl|dt|em|embed|fieldset|figcaption|figure|font|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hgroup|hr|html|i|iframe|img|input|ins|keygen|kbd|label|legend|li|link|map|mark|menu|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|s|samp|script|section|select|small|source|span|strike|strong|style|sub|summary|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|u|ul|var|video|wbr|xmp".split("|")), + l = "\\-?(?:(?:[0-9]+)|(?:[0-9]*\\.[0-9]+))"; + this.$rules = { + start: [{ + token: "comment", + regex: "\\/\\/.*$" + }, { + token: "comment", + merge: !0, + regex: "\\/\\*", + next: "comment" + }, { + token: "string", + regex: '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]' + }, { + token: "string", + regex: "['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']" + }, { + token: "constant.numeric", + regex: l + "(?:em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)" + }, { + token: "constant.numeric", + regex: "#[a-f0-9]{6}" + }, { + token: "constant.numeric", + regex: "#[a-f0-9]{3}" + }, { + token: "constant.numeric", + regex: l + }, { + token: function(e) { + return i.hasOwnProperty(e) ? "keyword" : "variable" + }, + regex: "@[a-z0-9_\\-@]*\\b" + }, { + token: function(o) { + return e.hasOwnProperty(o.toLowerCase()) ? "support.type" : i.hasOwnProperty(o) ? "keyword" : r.hasOwnProperty(o) ? "constant.language" : t.hasOwnProperty(o) ? "support.function" : n.hasOwnProperty(o.toLowerCase()) ? "support.constant.color" : a.hasOwnProperty(o.toLowerCase()) ? "variable.language" : "text" + }, + regex: "\\-?[@a-z_][@a-z0-9_\\-]*" + }, { + token: "variable.language", + regex: "#[a-z0-9-_]+" + }, { + token: "variable.language", + regex: "\\.[a-z0-9-_]+" + }, { + token: "variable.language", + regex: ":[a-z0-9-_]+" + }, { + token: "constant", + regex: "[a-z0-9-_]+" + }, { + token: "keyword.operator", + regex: "<|>|<=|>=|==|!=|-|%|#|\\+|\\$|\\+|\\*" + }, { + token: "paren.lparen", + regex: "[[({]" + }, { + token: "paren.rparen", + regex: "[\\])}]" + }, { + token: "text", + regex: "\\s+" + }], + comment: [{ + token: "comment", + regex: ".*?\\*\\/", + next: "start" + }, { + token: "comment", + merge: !0, + regex: ".+" + }] + } + }; + r.inherits(i, n), t.LessHighlightRules = i +}), define("ace/mode/matching_brace_outdent", ["require", "exports", "module", "ace/range"], function(e, t) { + var r = e("../range").Range, + o = function() {}; + (function() { + this.checkOutdent = function(e, t) { + return /^\s+$/.test(e) ? /^\s*\}/.test(t) : !1 + }, this.autoOutdent = function(e, t) { + var o = e.getLine(t), + n = o.match(/^(\s*\})/); + if (!n) return 0; + var i = n[1].length, + a = e.findMatchingBracket({ + row: t, + column: i + }); + if (!a || a.row == t) return 0; + var l = this.$getIndent(e.getLine(a.row)); + e.replace(new r(t, 0, t, i - 1), l) + }, this.$getIndent = function(e) { + var t = e.match(/^(\s+)/); + return t ? t[1] : "" + } + }).call(o.prototype), t.MatchingBraceOutdent = o +}), define("ace/mode/folding/cstyle", ["require", "exports", "module", "ace/lib/oop", "ace/range", "ace/mode/folding/fold_mode"], function(e, t) { + var r = e("../../lib/oop"), + o = e("../../range").Range, + n = e("./fold_mode").FoldMode, + i = t.FoldMode = function() {}; + r.inherits(i, n), + function() { + this.foldingStartMarker = /(\{|\[)[^\}\]]*$|^\s*(\/\*)/, this.foldingStopMarker = /^[^\[\{]*(\}|\])|^[\s\*]*(\*\/)/, this.getFoldWidgetRange = function(e, t, r) { + var n = e.getLine(r), + i = n.match(this.foldingStartMarker); + if (i) { + var a = i.index; + if (i[1]) return this.openingBracketBlock(e, i[1], r, a); + var l = e.getCommentFoldRange(r, a + i[0].length); + return l.end.column -= 2, l + } + if ("markbeginend" === t) { + var i = n.match(this.foldingStopMarker); + if (i) { + var a = i.index + i[0].length; + if (i[2]) { + var l = e.getCommentFoldRange(r, a); + return l.end.column -= 2, l + } + var s = { + row: r, + column: a + }, + d = e.$findOpeningBracket(i[1], s); + if (!d) return; + return d.column++, s.column--, o.fromPoints(d, s) + } + } + } + }.call(i.prototype) +}), define("ace/mode/folding/fold_mode", ["require", "exports", "module", "ace/range"], function(e, t) { + var r = e("../../range").Range, + o = t.FoldMode = function() {}; + (function() { + this.foldingStartMarker = null, this.foldingStopMarker = null, this.getFoldWidget = function(e, t, r) { + var o = e.getLine(r); + return this.foldingStartMarker.test(o) ? "start" : "markbeginend" == t && this.foldingStopMarker && this.foldingStopMarker.test(o) ? "end" : "" + }, this.getFoldWidgetRange = function() { + return null + }, this.indentationBlock = function(e, t, o) { + for (var n = /^\s*/, i = t, a = t, l = e.getLine(t), s = o || l.length, d = l.match(n)[0].length, c = e.getLength(); ++t < c;) { + l = e.getLine(t); + var g = l.match(n)[0].length; + if (g != l.length) { + if (d >= g) break; + a = t + } + } + if (a > i) { + var u = e.getLine(a).length; + return new r(i, s, a, u) + } + }, this.openingBracketBlock = function(e, t, o, n, i, a) { + var l = { + row: o, + column: n + 1 + }, + s = e.$findClosingBracket(t, l, i, a); + if (s) { + var d = e.foldWidgets[s.row]; + return null == d && (d = this.getFoldWidget(e, s.row)), "start" == d && (s.row--, s.column = e.getLine(s.row).length), r.fromPoints(l, s) + } + } + }).call(o.prototype) +}); diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 325cbdc6d87..054850b0926 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -5,12 +5,16 @@ import cStringIO import io import json import logging +import os +import re import time import werkzeug.wrappers from PIL import Image, ImageFont, ImageDraw +from lxml import etree, html from odoo.http import request from odoo import http, tools +from odoo.modules.module import get_resource_path, get_module_path logger = logging.getLogger(__name__) @@ -223,11 +227,196 @@ class Web_Editor(http.Controller): attachments_to_remove.unlink() return removal_blocked_by - @http.route('/web_editor/get_assets_editor_resources', type='json', auth='user', website=True) - def get_assets_editor_resources(self, key, bundles=False): - """ Get inherit view's informations of the template ``key``. - returns templates info (which can be active or not) - ``bundles=True`` returns also the asset bundles - """ + ## The get_assets_editor_resources route is in charge of transmitting the resources the assets + ## editor needs to work. + ## @param key - the xml_id or id of the view the resources are related to + ## @param get_views - True if the views must be fetched (default to True) + ## @param get_less - True if the style must be fetched (default to True) + ## @param bundles - True if the bundles views must be fetched (default to False) + ## @param bundles_restriction - Names of the bundle in which to look for less files (if empty, search in all of them) + ## @returns a dictionary with views info in the views key and style info in the less key + @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user") + def get_assets_editor_resources(self, key, get_views=True, get_less=True, bundles=False, bundles_restriction=[]): + # Related views must be fetched if the user wants the views and/or the style views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles) - return views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']) + views = views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']) + + less_files_data_by_bundle = [] + + # Load less only if asked by the user + if get_less: + # Compile regex outside of the loop + # This will used to exclude library less files from the result + excluded_url_matcher = re.compile("^(.+/lib/.+)|(.+import_bootstrap.less)$") + + # Load already customized less files attachments + custom_attachments = request.env["ir.attachment"].search([("url", "=like", self._make_custom_less_file_url("%%.%%", "%%"))]) + + # First check the t-call-assets used in the related views + url_infos = dict() + for v in views: + for asset_call_node in etree.fromstring(v["arch"]).xpath("//t[@t-call-assets]"): + if asset_call_node.get("t-css") == "false": + continue + asset_name = asset_call_node.get("t-call-assets") + + # Loop through bundle files to search for LESS file info + less_files_data = [] + for file_info in request.env["ir.qweb"]._get_asset_content(asset_name, {})[0]: + if file_info["atype"] != "text/less": + continue + url = file_info["url"] + + # Exclude library files (see regex above) + if excluded_url_matcher.match(url): + continue + + # Check if the file is customized and get bundle/path info + less_file_data = self._match_less_file_url(url) + if not less_file_data: + continue + + # Save info (arch will be fetched later) + url_infos[url] = less_file_data + less_files_data.append(url) + + # Less data is returned sorted by bundle, with the bundles names and xmlids + if len(less_files_data): + less_files_data_by_bundle.append([dict(xmlid=asset_name, name=request.env.ref(asset_name).name), less_files_data]) + + # Filter bundles/files: + # - A file which appears in multiple bundles only appears in the first one (the first in the DOM) + # - Only keep bundles with files which appears in the asked bundles and only keep those files + for i in range(0, len(less_files_data_by_bundle)): + bundle_1 = less_files_data_by_bundle[i] + for j in range(0, len(less_files_data_by_bundle)): + bundle_2 = less_files_data_by_bundle[j] + # In unwanted bundles, keep only the files which are in wanted bundles too (less_helpers) + if bundle_1[0]["xmlid"] not in bundles_restriction and bundle_2[0]["xmlid"] in bundles_restriction: + bundle_1[1] = [item_1 for item_1 in bundle_1[1] if item_1 in bundle_2[1]] + for i in range(0, len(less_files_data_by_bundle)): + bundle_1 = less_files_data_by_bundle[i] + for j in range(i+1, len(less_files_data_by_bundle)): + bundle_2 = less_files_data_by_bundle[j] + # In every bundle, keep only the files which were not found in previous bundles + bundle_2[1] = [item_2 for item_2 in bundle_2[1] if item_2 not in bundle_1[1]] + + # Only keep bundles which still have files and that were requested + less_files_data_by_bundle = [ + data for data in less_files_data_by_bundle + if (len(data[1]) > 0 and (not bundles_restriction or data[0]["xmlid"] in bundles_restriction)) + ] + + # Fetch the arch of each kept file, in each bundle + for bundle_data in less_files_data_by_bundle: + for i in range(0, len(bundle_data[1])): + url = bundle_data[1][i] + url_info = url_infos[url] + + content = None + if url_info["customized"]: + # If the file is already customized, the content is found in the corresponding attachment + content = custom_attachments.filtered(lambda a: a.url == url).datas.decode("base64") + else: + # If the file is not yet customized, the content is found by reading the local less file + module = url_info["module"] + module_path = get_module_path(module) + module_resource_path = get_resource_path(module, url_info["resource_path"]) + if module_path and module_resource_path: + module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/' + module_resource_path = os.path.normpath(module_resource_path) + if module_resource_path.startswith(module_path): + with open(module_resource_path, "rb") as f: + content = f.read() + + bundle_data[1][i] = dict( + url = "/%s/%s" % (url_info["module"], url_info["resource_path"]), + arch = content, + customized = url_info["customized"], + ) + + return dict( + views = get_views and views or [], + less = get_less and less_files_data_by_bundle or [], + ) + + ## The save_less route is in charge of saving a given modification of a LESS file. + ## @param url - the original url of the LESS file which has to be modified + ## @param bundle_xmlid - the xmlid of the bundle in which the LESS file addition can be found + ## @param content - the new content of the LESS file + @http.route("/web_editor/save_less", type="json", auth="user") + def save_less(self, url, bundle_xmlid, content): + IrAttachment = request.env["ir.attachment"] + + custom_url = self._make_custom_less_file_url(url, bundle_xmlid) + + # Check if the file to save had already been modified + custom_attachment = IrAttachment.search([("url", "=", custom_url)]) + if custom_attachment: + # If it was already modified, simply override the corresponding attachment content + custom_attachment.write({"datas": content.encode("utf-8").encode("base64")}) + else: + # If not, create a new attachment to copy the original LESS file content, with its modifications + IrAttachment.create(dict( + name = custom_url, + type = "binary", + mimetype = "text/less", + datas = content.encode("utf-8").encode("base64"), + datas_fname = url.split("/")[-1], + url = custom_url, # Having an attachment of "binary" type with an non empty "url" field + # is quite of an hack. This allows to fetch the "datas" field by adding + # a with the "url" content in the bundle template (see qweb) + )) + + # Create a view to extend the template which adds the original file to link the new modified version instead + IrUiView = request.env["ir.ui.view"] + view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(lambda v: v.arch.find(url) >= 0) + IrUiView.create(dict( + name = custom_url, + mode = "extension", + inherit_id = view_to_xpath.id, + arch = """ + + + %(new_url)s + + + """ % dict( + inherit_xml_id = view_to_xpath.xml_id, + name = custom_url, + url_to_replace = url, + new_url = custom_url, + ) + )) + + request.env["ir.qweb"].clear_caches() + + ## The reset_less route is in charge of reverting all the changes that were done to a less file. + ## @param url - the original URL of the LESS file to reset + ## @param bundle_xmlid - the xmlid of the bundle in which the LESS file addition can be found + @http.route("/web_editor/reset_less", type="json", auth="user") + def reset_less(self, url, bundle_xmlid): + IrAttachment = request.env["ir.attachment"] + IrUiView = request.env["ir.ui.view"] + + custom_url = self._make_custom_less_file_url(url, bundle_xmlid) + + # Simply delete the attachement which contains the modified less file and the xpath view which links it + IrAttachment.search([("url", "=", custom_url)]).unlink() + IrUiView.search([("name", "=", custom_url)]).unlink() + + def _make_custom_less_file_url(self, url, bundle): + parts = url.rsplit(".", 1) + return "%s.custom.%s.%s" % (parts[0], bundle, parts[1]) + + _match_less_file_url_regex = re.compile("^/(\w+)/(.+?)(\.custom\.(.+))?\.(\w+)$") + def _match_less_file_url(self, url): + m = self._match_less_file_url_regex.match(url) + if not m: + return False + return dict( + module = m.group(1), + resource_path = "%s.%s" % (m.group(2), m.group(5)), + customized = bool(m.group(3)), + bundle = m.group(4) or False + ) 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.options = _.defaults({}, options, {position: 'right'}); - this.views = {}; - this.buffers = {}; + 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. @@ -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($("", { + label: bundleInfos[0].name, + }).appendTo(self.$lists.less); + _.each(bundleInfos[1], function (lessInfo) { + $optgroup.append($("