diff --git a/owl.js b/owl.js index 48da879c..b7a45bda 100644 --- a/owl.js +++ b/owl.js @@ -103,6 +103,7 @@ if (!this.__observer__.allowMutations) { throw new Error(`Array cannot be changed here")`); } + this.__observer__.rev++; this.__observer__.notifyChange(); this.__owl__.rev++; let parent = this; @@ -138,7 +139,6 @@ } notifyCB() { } notifyChange() { - this.rev++; this.dirty = true; Promise.resolve().then(() => { if (this.dirty) { @@ -168,20 +168,21 @@ } } set(target, key, value) { + this.rev++; this._addProp(target, key, value); target.__owl__.rev++; this.notifyChange(); } _observeObj(obj, parent) { const keys = Object.keys(obj); - obj.__owl__ = { rev: 1, deepRev: 1, parent }; + obj.__owl__ = { rev: this.rev, deepRev: this.rev, parent }; Object.defineProperty(obj, "__owl__", { enumerable: false }); for (let key of keys) { this._addProp(obj, key, obj[key]); } } _observeArr(arr, parent) { - arr.__owl__ = { rev: 1, deepRev: 1, parent }; + arr.__owl__ = { rev: this.rev, deepRev: this.rev, parent }; Object.defineProperty(arr, "__owl__", { enumerable: false }); arr.__proto__ = Object.create(ModifiedArrayProto); arr.__proto__.__observer__ = this; @@ -198,6 +199,7 @@ }, set(newVal) { if (newVal !== value) { + self.rev++; if (!self.allowMutations) { throw new Error(`Observed state cannot be changed here! (key: "${key}", val: "${newVal}")`); } @@ -865,6 +867,11 @@ }; const patch = init([eventListenersModule, attrsModule, propsModule]); + // If a component does not define explicitely a template + // key, it needs to find a template with its name (or a parent's). This is + // qweb dependant, so we need a place to store this information indexed by + // qweb instances. + const TEMPLATE_MAP = {}; //------------------------------------------------------------------------------ // Widget //------------------------------------------------------------------------------ @@ -894,8 +901,6 @@ */ constructor(parent, props) { super(); - this.template = "default"; - this.inlineTemplate = null; this.refs = {}; // is this a good idea? // Pro: if props is empty, we can create easily a widget @@ -915,7 +920,6 @@ this.__owl__ = { id: id, vnode: null, - isStarted: false, isMounted: false, isDestroyed: false, parent: p, @@ -924,7 +928,8 @@ renderId: 1, renderPromise: null, renderProps: props || null, - boundHandlers: {} + boundHandlers: {}, + mountedHandlers: {} }; } get el() { @@ -1024,6 +1029,9 @@ } } this.__owl__.isMounted = true; + for (let key in this.__owl__.mountedHandlers) { + this.__owl__.mountedHandlers[key](); + } this.mounted(); } _callWillUnmount() { @@ -1151,11 +1159,35 @@ if (this.__owl__.isDestroyed) { return Promise.resolve(h("div")); } - this.__owl__.isStarted = true; - if (this.inlineTemplate) { - this.env.qweb.addTemplate(this.inlineTemplate, this.inlineTemplate, true); + const qweb = this.env.qweb; + if (!this.template) { + let tmap = TEMPLATE_MAP[qweb.id]; + if (!tmap) { + tmap = {}; + TEMPLATE_MAP[qweb.id] = tmap; + } + let p = this.constructor; + let name = p.name; + let template = tmap[name]; + if (template) { + this.template = template; + } + else { + while ((template = p.name) && + !(template in qweb.templates) && + p !== Component) { + p = p.__proto__; + } + if (p === Component) { + this.template = "default"; + } + else { + tmap[name] = template; + this.template = template; + } + } } - this.__owl__.render = this.env.qweb.render.bind(this.env.qweb, this.inlineTemplate || this.template); + this.__owl__.render = qweb.render.bind(qweb, this.template); this._observeState(); return this._render(); } @@ -1172,6 +1204,7 @@ let vnode = this.__owl__.render(this, { promises, handlers: this.__owl__.boundHandlers, + mountedHandlers: this.__owl__.mountedHandlers, forceUpdate: force, patchQueue }); @@ -1194,8 +1227,7 @@ */ _mount(vnode, elm) { this.__owl__.vnode = patch(elm, vnode); - if (this.__owl__.parent && - this.__owl__.parent.__owl__.isMounted && + if (this.__owl__.parent.__owl__.isMounted && !this.__owl__.isMounted) { this._callMounted(); } @@ -1216,181 +1248,6 @@ } } - /** - * Owl Utils - * - * We have here a small collection of utility functions: - * - * - escape - * - memoize - * - debounce - * - patch - * - unpatch - * - loadTemplates - * - loadJS - * - whenReady - * - parseXML - */ - function escape(str) { - if (str === undefined) { - return ""; - } - if (typeof str === "number") { - return String(str); - } - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, "'") - .replace(/`/g, "`"); - } - function memoize(f, hash) { - if (!hash) { - hash = args => args.map(a => String(a)).join(","); - } - let cache = {}; - function memoizedFunction(...args) { - let hashValue = hash(args); - if (!(hashValue in cache)) { - cache[hashValue] = f(...args); - } - return cache[hashValue]; - } - return memoizedFunction; - } - /** - * Returns a function, that, as long as it continues to be invoked, will not - * be triggered. The function will be called after it stops being called for - * N milliseconds. If `immediate` is passed, trigger the function on the - * leading edge, instead of the trailing. - * - * Inspired by https://davidwalsh.name/javascript-debounce-function - */ - function debounce(func, wait, immediate) { - let timeout; - return function () { - const context = this; - const args = arguments; - function later() { - timeout = null; - if (!immediate) { - func.apply(context, args); - } - } - const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) { - func.apply(context, args); - } - }; - } - function patch$1(C, patchName, patch) { - const proto = C.prototype; - if (!proto.__patches) { - proto.__patches = { - origMethods: {}, - patches: {}, - current: [] - }; - } - if (proto.__patches.patches[patchName]) { - throw new Error(`Patch [${patchName}] already exists`); - } - proto.__patches.patches[patchName] = patch; - applyPatch(proto, patch); - proto.__patches.current.push(patchName); - function applyPatch(proto, patch) { - Object.keys(patch).forEach(function (methodName) { - const method = patch[methodName]; - if (typeof method === "function") { - const original = proto[methodName]; - if (!(methodName in proto.__patches.origMethods)) { - proto.__patches.origMethods[methodName] = original; - } - proto[methodName] = function (...args) { - this._super = original; - return method.call(this, ...args); - }; - } - }); - } - } - function unpatch(C, patchName) { - const proto = C.prototype; - const patchInfo = proto.__patches; - delete proto.__patches; - // reset to original - for (let k in patchInfo.origMethods) { - proto[k] = patchInfo.origMethods[k]; - } - // apply other patches - for (let name of patchInfo.current) { - if (name !== patchName) { - patch$1(C, name, patchInfo.patches[name]); - } - } - } - async function loadTemplates(url) { - const result = await fetch(url); - if (!result.ok) { - throw new Error("Error while fetching xml templates"); - } - let templates = await result.text(); - templates = templates.replace(//g, ""); - return templates; - } - const loadedScripts = {}; - function loadJS(url) { - if (url in loadedScripts) { - return loadedScripts[url]; - } - const promise = new Promise(function (resolve, reject) { - const script = document.createElement("script"); - script.type = "text/javascript"; - script.src = url; - script.onload = function () { - resolve(); - }; - script.onerror = function () { - reject(`Error loading file '${url}'`); - }; - const head = document.head || document.getElementsByTagName("head")[0]; - head.appendChild(script); - }); - loadedScripts[url] = promise; - return promise; - } - function whenReady(fn) { - if (document.readyState === "complete") { - fn(); - } - else { - document.addEventListener("DOMContentLoaded", fn, false); - } - } - function parseXML(xml) { - const parser = new DOMParser(); - const doc = parser.parseFromString(xml, "text/xml"); - if (doc.getElementsByTagName("parsererror").length) { - throw new Error("Invalid XML in template"); - } - return doc; - } - - var _utils = /*#__PURE__*/Object.freeze({ - escape: escape, - memoize: memoize, - debounce: debounce, - patch: patch$1, - unpatch: unpatch, - loadTemplates: loadTemplates, - loadJS: loadJS, - whenReady: whenReady, - parseXML: parseXML - }); - //------------------------------------------------------------------------------ // Const/global stuff/helpers //------------------------------------------------------------------------------ @@ -1420,6 +1277,11 @@ key: 1 }; const DIRECTIVES = []; + const NODE_HOOKS_PARAMS = { + create: "(_, n)", + insert: "vn", + remove: "(vn, rm)" + }; const UTILS = { h: h, objectToAttrString(obj) { @@ -1432,6 +1294,416 @@ return classes.join(" "); } }; + function parseXML(xml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, "text/xml"); + if (doc.getElementsByTagName("parsererror").length) { + throw new Error("Invalid XML in template"); + } + return doc; + } + let nextID = 1; + //------------------------------------------------------------------------------ + // QWeb rendering engine + //------------------------------------------------------------------------------ + class QWeb { + constructor(data) { + this.templates = {}; + this.utils = UTILS; + // the id field is useful to be able to hash qweb instances. The current + // use case is that component's templates are qweb dependant, and need to be + // able to map a qweb instance to a template name. + this.id = nextID++; + if (data) { + this.addTemplates(data); + } + this.addTemplate("default", "
"); + } + static addDirective(directive) { + DIRECTIVES.push(directive); + DIRECTIVE_NAMES[directive.name] = 1; + DIRECTIVES.sort((d1, d2) => d1.priority - d2.priority); + if (directive.extraNames) { + directive.extraNames.forEach(n => (DIRECTIVE_NAMES[n] = 1)); + } + } + /** + * Add a template to the internal template map. Note that it is not + * immediately compiled. + */ + addTemplate(name, xmlString) { + const doc = parseXML(xmlString); + if (!doc.firstChild) { + throw new Error("Invalid template (should not be empty)"); + } + this._addTemplate(name, doc.firstChild); + } + /** + * Load templates from a xml (as a string). This will look up for the first + * tag, and will consider each child of this as a template, with + * the name given by the t-name attribute. + */ + addTemplates(xmlstr) { + const doc = parseXML(xmlstr); + const templates = doc.getElementsByTagName("templates")[0]; + if (!templates) { + return; + } + for (let elem of templates.children) { + const name = elem.getAttribute("t-name"); + this._addTemplate(name, elem); + } + } + _addTemplate(name, elem) { + if (name in this.templates) { + throw new Error(`Template ${name} already defined`); + } + this._processTemplate(elem); + const template = { + elem, + fn: (context, extra) => { + const compiledFunction = this._compile(name, elem); + template.fn = compiledFunction; + return compiledFunction.call(this, context, extra); + } + }; + this.templates[name] = template; + } + _processTemplate(elem) { + let tbranch = elem.querySelectorAll("[t-elif], [t-else]"); + for (let i = 0, ilen = tbranch.length; i < ilen; i++) { + let node = tbranch[i]; + let prevElem = node.previousElementSibling; + let pattr = function (name) { + return prevElem.getAttribute(name); + }; + let nattr = function (name) { + return +!!node.getAttribute(name); + }; + if (prevElem && (pattr("t-if") || pattr("t-elif"))) { + if (pattr("t-foreach")) { + throw new Error("t-if cannot stay at the same level as t-foreach when using t-elif or t-else"); + } + if (["t-if", "t-elif", "t-else"].map(nattr).reduce(function (a, b) { + return a + b; + }) > 1) { + throw new Error("Only one conditional branching directive is allowed per node"); + } + // All text nodes between branch nodes are removed + let textNode; + while ((textNode = node.previousSibling) !== prevElem) { + if (textNode.nodeValue.trim().length) { + throw new Error("text is not allowed between branching directives"); + } + textNode.remove(); + } + } + else { + throw new Error("t-elif and t-else directives must be preceded by a t-if or t-elif directive"); + } + } + } + /** + * Render a template + * + * @param {string} name the template should already have been added + */ + render(name, context = {}, extra = null) { + const template = this.templates[name]; + if (!template) { + throw new Error(`Template ${name} does not exist`); + } + return template.fn.call(this, context, extra); + } + _compile(name, elem) { + const isDebug = elem.attributes.hasOwnProperty("t-debug"); + const ctx = new Context(name); + this._compileNode(elem, ctx); + if (ctx.shouldProtectContext) { + ctx.code.unshift(" context = Object.create(context);"); + } + if (ctx.shouldDefineOwner) { + // this is necessary to prevent some directives (t-forach for ex) to + // pollute the rendering context by adding some keys in it. + ctx.code.unshift(" let owner = context;"); + } + if (!ctx.rootNode) { + throw new Error("A template should have one root node"); + } + ctx.addLine(`return vn${ctx.rootNode};`); + let template; + try { + template = new Function("context", "extra", ctx.code.join("\n")); + } + catch (e) { + throw new Error(`Invalid generated code while compiling template '${ctx.templateName.replace(/`/g, "'")}': ${e.message}`); + } + if (isDebug) { + console.log(`Template: ${this.templates[name].elem.outerHTML}\nCompiled code:\n` + + template.toString()); + } + return template; + } + /** + * Generate code from an xml node + * + */ + _compileNode(node, ctx) { + if (!(node instanceof Element)) { + // this is a text node, there are no directive to apply + let text = node.textContent; + if (!ctx.inPreTag) { + if (lineBreakRE.test(text) && !text.trim()) { + return; + } + text = text.replace(whitespaceRE, " "); + } + if (ctx.parentNode) { + ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`); + } + else { + // this is an unusual situation: this text node is the result of the + // template rendering. + let nodeID = ctx.generateID(); + ctx.addLine(`var vn${nodeID} = {text: \`${text}\`};`); + ctx.rootContext.rootNode = nodeID; + ctx.rootContext.parentNode = nodeID; + } + return; + } + const attributes = node.attributes; + const validDirectives = []; + let withHandlers = false; + // maybe this is not optimal: we iterate on all attributes here, and again + // just after for each directive. + for (let i = 0; i < attributes.length; i++) { + let attrName = attributes[i].name; + if (attrName.startsWith("t-")) { + let dName = attrName.slice(2).split("-")[0]; + if (!(dName in DIRECTIVE_NAMES)) { + throw new Error(`Unknown QWeb directive: '${attrName}'`); + } + } + } + for (let directive of DIRECTIVES) { + let fullName; + let value; + for (let i = 0; i < attributes.length; i++) { + const name = attributes[i].name; + if (name === "t-" + directive.name || + name.startsWith("t-" + directive.name + "-")) { + fullName = name; + value = attributes[i].textContent; + validDirectives.push({ directive, value, fullName }); + if (directive.name === "on") { + withHandlers = true; + } + } + } + } + for (let { directive, value, fullName } of validDirectives) { + if (directive.atNodeEncounter) { + const isDone = directive.atNodeEncounter({ + node, + qweb: this, + ctx, + fullName, + value + }); + if (isDone) { + return; + } + } + } + if (node.nodeName !== "t") { + let nodeID = this._compileGenericNode(node, ctx, withHandlers); + ctx = ctx.withParent(nodeID); + let nodeHooks = {}; + let addNodeHook = function (hook, handler) { + nodeHooks[hook] = nodeHooks[hook] || []; + nodeHooks[hook].push(handler); + }; + for (let { directive, value, fullName } of validDirectives) { + if (directive.atNodeCreation) { + directive.atNodeCreation({ + node, + qweb: this, + ctx, + fullName, + value, + nodeID, + addNodeHook + }); + } + } + if (Object.keys(nodeHooks).length) { + ctx.addLine(`p${nodeID}.hook = {`); + for (let hook in nodeHooks) { + ctx.addLine(` ${hook}: ${NODE_HOOKS_PARAMS[hook]} => {`); + for (let handler of nodeHooks[hook]) { + ctx.addLine(` ${handler}`); + } + ctx.addLine(` },`); + } + ctx.addLine(`};`); + } + } + if (node.nodeName === "pre") { + ctx = ctx.subContext("inPreTag", true); + } + this._compileChildren(node, ctx); + for (let { directive, value, fullName } of validDirectives) { + if (directive.finalize) { + directive.finalize({ node, qweb: this, ctx, fullName, value }); + } + } + } + _compileGenericNode(node, ctx, withHandlers = true) { + // nodeType 1 is generic tag + if (node.nodeType !== 1) { + throw new Error("unsupported node type"); + } + const attributes = node.attributes; + const attrs = []; + const props = []; + const tattrs = []; + function handleBooleanProps(key, val) { + let isProp = false; + if (node.nodeName === "input" && key === "checked") { + let type = node.getAttribute("type"); + if (type === "checkbox" || type === "radio") { + isProp = true; + } + } + if (node.nodeName === "option" && key === "selected") { + isProp = true; + } + if (key === "disabled" && DISABLED_TAGS.indexOf(node.nodeName) > -1) { + isProp = true; + } + if ((key === "readonly" && node.nodeName === "input") || + node.nodeName === "textarea") { + isProp = true; + } + if (isProp) { + props.push(`${key}: _${val}`); + } + } + function formatter(expr) { + return "${" + ctx.formatExpression(expr) + "}"; + } + for (let i = 0; i < attributes.length; i++) { + let name = attributes[i].name; + const value = attributes[i].textContent; + // regular attributes + if (!name.startsWith("t-") && + !node.getAttribute("t-attf-" + name)) { + const attID = ctx.generateID(); + ctx.addLine(`var _${attID} = '${value}';`); + if (!name.match(/^[a-zA-Z]+$/)) { + // attribute contains 'non letters' => we want to quote it + name = '"' + name + '"'; + } + attrs.push(`${name}: _${attID}`); + handleBooleanProps(name, attID); + } + // dynamic attributes + if (name.startsWith("t-att-")) { + let attName = name.slice(6); + let formattedValue = ctx.formatExpression(ctx.getValue(value)); + if (formattedValue[0] === "{" && + formattedValue[formattedValue.length - 1] === "}") { + formattedValue = `this.utils.objectToAttrString(${formattedValue})`; + } + const attID = ctx.generateID(); + if (!attName.match(/^[a-zA-Z]+$/)) { + // attribute contains 'non letters' => we want to quote it + attName = '"' + attName + '"'; + } + // we need to combine dynamic with non dynamic attributes: + // class="a" t-att-class="'yop'" should be rendered as class="a yop" + const attValue = node.getAttribute(attName); + if (attValue) { + const attValueID = ctx.generateID(); + ctx.addLine(`var _${attValueID} = ${formattedValue};`); + formattedValue = `'${attValue}' + (_${attValueID} ? ' ' + _${attValueID} : '')`; + const attrIndex = attrs.findIndex(att => att.startsWith(attName + ":")); + attrs.splice(attrIndex, 1); + } + ctx.addLine(`var _${attID} = ${formattedValue};`); + attrs.push(`${attName}: _${attID}`); + handleBooleanProps(attName, attID); + } + if (name.startsWith("t-attf-")) { + let attName = name.slice(7); + if (!attName.match(/^[a-zA-Z]+$/)) { + // attribute contains 'non letters' => we want to quote it + attName = '"' + attName + '"'; + } + const formattedExpr = value + .replace(/\{\{.*?\}\}/g, s => formatter(s.slice(2, -2))) + .replace(/\#\{.*?\}/g, s => formatter(s.slice(2, -1))); + const attID = ctx.generateID(); + let staticVal = node.getAttribute(attName); + if (staticVal) { + ctx.addLine(`var _${attID} = '${staticVal} ' + \`${formattedExpr}\`;`); + } + else { + ctx.addLine(`var _${attID} = \`${formattedExpr}\`;`); + } + attrs.push(`${attName}: _${attID}`); + } + // t-att= attributes + if (name === "t-att") { + let id = ctx.generateID(); + ctx.addLine(`var _${id} = ${ctx.formatExpression(value)};`); + tattrs.push(id); + } + } + let nodeID = ctx.generateID(); + let nodeKey = node.getAttribute("t-key"); + if (nodeKey) { + nodeKey = ctx.formatExpression(nodeKey); + } + else { + nodeKey = nodeID; + } + const parts = [`key:${nodeKey}`]; + if (attrs.length + tattrs.length > 0) { + parts.push(`attrs:{${attrs.join(",")}}`); + } + if (props.length > 0) { + parts.push(`props:{${props.join(",")}}`); + } + if (withHandlers) { + parts.push(`on:{}`); + } + ctx.addLine(`let c${nodeID} = [], p${nodeID} = {${parts.join(",")}};`); + for (let id of tattrs) { + ctx.addIf(`_${id} instanceof Array`); + ctx.addLine(`p${nodeID}.attrs[_${id}[0]] = _${id}[1];`); + ctx.addElse(); + ctx.addLine(`for (let key in _${id}) {`); + ctx.indent(); + ctx.addLine(`p${nodeID}.attrs[key] = _${id}[key];`); + ctx.dedent(); + ctx.addLine(`}`); + ctx.closeIf(); + } + ctx.addLine(`var vn${nodeID} = h('${node.nodeName}', p${nodeID}, c${nodeID});`); + if (ctx.parentNode) { + ctx.addLine(`c${ctx.parentNode}.push(vn${nodeID});`); + } + return nodeID; + } + _compileChildren(node, ctx) { + if (node.childNodes.length > 0) { + for (let child of Array.from(node.childNodes)) { + this._compileNode(child, ctx); + } + } + } + } //------------------------------------------------------------------------------ // Compilation Context //------------------------------------------------------------------------------ @@ -1562,383 +1834,6 @@ return result; } } - //------------------------------------------------------------------------------ - // QWeb rendering engine - //------------------------------------------------------------------------------ - class QWeb { - constructor(data) { - this.templates = {}; - this.utils = UTILS; - if (data) { - this.loadTemplates(data); - } - } - static addDirective(directive) { - DIRECTIVES.push(directive); - DIRECTIVE_NAMES[directive.name] = 1; - DIRECTIVES.sort((d1, d2) => d1.priority - d2.priority); - if (directive.extraNames) { - directive.extraNames.forEach(n => (DIRECTIVE_NAMES[n] = 1)); - } - } - /** - * Add a template to the internal template map. Note that it is not - * immediately compiled. - */ - addTemplate(name, xmlString, allowDuplicates = false) { - if (name in this.templates && allowDuplicates) { - return; - } - const doc = parseXML(xmlString); - if (!doc.firstChild) { - throw new Error("Invalid template (should not be empty)"); - } - this._addTemplate(name, doc.firstChild); - } - _addTemplate(name, elem) { - if (name in this.templates) { - throw new Error(`Template ${name} already defined`); - } - this._processTemplate(elem); - const template = { - elem, - fn: (context, extra) => { - const compiledFunction = this._compile(name, elem); - template.fn = compiledFunction; - return compiledFunction.call(this, context, extra); - } - }; - this.templates[name] = template; - } - _processTemplate(elem) { - let tbranch = elem.querySelectorAll("[t-elif], [t-else]"); - for (let i = 0, ilen = tbranch.length; i < ilen; i++) { - let node = tbranch[i]; - let prevElem = node.previousElementSibling; - let pattr = function (name) { - return prevElem.getAttribute(name); - }; - let nattr = function (name) { - return +!!node.getAttribute(name); - }; - if (prevElem && (pattr("t-if") || pattr("t-elif"))) { - if (pattr("t-foreach")) { - throw new Error("t-if cannot stay at the same level as t-foreach when using t-elif or t-else"); - } - if (["t-if", "t-elif", "t-else"].map(nattr).reduce(function (a, b) { - return a + b; - }) > 1) { - throw new Error("Only one conditional branching directive is allowed per node"); - } - // All text nodes between branch nodes are removed - let textNode; - while ((textNode = node.previousSibling) !== prevElem) { - if (textNode.nodeValue.trim().length) { - throw new Error("text is not allowed between branching directives"); - } - textNode.remove(); - } - } - else { - throw new Error("t-elif and t-else directives must be preceded by a t-if or t-elif directive"); - } - } - } - /** - * Load templates from a xml (as a string). This will look up for the first - * tag, and will consider each child of this as a template, with - * the name given by the t-name attribute. - */ - loadTemplates(xmlstr) { - const doc = parseXML(xmlstr); - const templates = doc.getElementsByTagName("templates")[0]; - if (!templates) { - return; - } - for (let elem of templates.children) { - const name = elem.getAttribute("t-name"); - this._addTemplate(name, elem); - } - } - /** - * Render a template - * - * @param {string} name the template should already have been added - */ - render(name, context = {}, extra = null) { - const template = this.templates[name]; - if (!template) { - throw new Error(`Template ${name} does not exist`); - } - return template.fn.call(this, context, extra); - } - _compile(name, elem) { - const isDebug = elem.attributes.hasOwnProperty("t-debug"); - const ctx = new Context(name); - this._compileNode(elem, ctx); - if (ctx.shouldProtectContext) { - ctx.code.unshift(" context = Object.create(context);"); - } - if (ctx.shouldDefineOwner) { - // this is necessary to prevent some directives (t-forach for ex) to - // pollute the rendering context by adding some keys in it. - ctx.code.unshift(" let owner = context;"); - } - if (!ctx.rootNode) { - throw new Error("A template should have one root node"); - } - ctx.addLine(`return vn${ctx.rootNode};`); - let template; - try { - template = new Function("context", "extra", ctx.code.join("\n")); - } - catch (e) { - throw new Error(`Invalid generated code while compiling template '${ctx.templateName.replace(/`/g, "'")}': ${e.message}`); - } - if (isDebug) { - console.log(`Template: ${this.templates[name].elem.outerHTML}\nCompiled code:\n` + - template.toString()); - } - return template; - } - /** - * Generate code from an xml node - * - */ - _compileNode(node, ctx) { - if (!(node instanceof Element)) { - // this is a text node, there are no directive to apply - let text = node.textContent; - if (!ctx.inPreTag) { - if (lineBreakRE.test(text) && !text.trim()) { - return; - } - text = text.replace(whitespaceRE, " "); - } - if (ctx.parentNode) { - ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`); - } - else { - // this is an unusual situation: this text node is the result of the - // template rendering. - let nodeID = ctx.generateID(); - ctx.addLine(`var vn${nodeID} = {text: \`${text}\`};`); - ctx.rootContext.rootNode = nodeID; - ctx.rootContext.parentNode = nodeID; - } - return; - } - const attributes = node.attributes; - const validDirectives = []; - let withHandlers = false; - // maybe this is not optimal: we iterate on all attributes here, and again - // just after for each directive. - for (let i = 0; i < attributes.length; i++) { - let attrName = attributes[i].name; - if (attrName.startsWith("t-")) { - let dName = attrName.slice(2).split("-")[0]; - if (!(dName in DIRECTIVE_NAMES)) { - throw new Error(`Unknown QWeb directive: '${attrName}'`); - } - } - } - for (let directive of DIRECTIVES) { - let fullName; - let value; - for (let i = 0; i < attributes.length; i++) { - const name = attributes[i].name; - if (name === "t-" + directive.name || - name.startsWith("t-" + directive.name + "-")) { - fullName = name; - value = attributes[i].textContent; - validDirectives.push({ directive, value, fullName }); - if (directive.name === "on") { - withHandlers = true; - } - } - } - } - for (let { directive, value, fullName } of validDirectives) { - if (directive.atNodeEncounter) { - const isDone = directive.atNodeEncounter({ - node, - qweb: this, - ctx, - fullName, - value - }); - if (isDone) { - return; - } - } - } - if (node.nodeName !== "t") { - let nodeID = this._compileGenericNode(node, ctx, withHandlers); - ctx = ctx.withParent(nodeID); - for (let { directive, value, fullName } of validDirectives) { - if (directive.atNodeCreation) { - directive.atNodeCreation({ - node, - qweb: this, - ctx, - fullName, - value, - nodeID - }); - } - } - } - if (node.nodeName === "pre") { - ctx = ctx.subContext("inPreTag", true); - } - this._compileChildren(node, ctx); - for (let { directive, value, fullName } of validDirectives) { - if (directive.finalize) { - directive.finalize({ node, qweb: this, ctx, fullName, value }); - } - } - } - _compileGenericNode(node, ctx, withHandlers = true) { - // nodeType 1 is generic tag - if (node.nodeType !== 1) { - throw new Error("unsupported node type"); - } - const attributes = node.attributes; - const attrs = []; - const props = []; - const tattrs = []; - function handleBooleanProps(key, val) { - let isProp = false; - if (node.nodeName === "input" && key === "checked") { - let type = node.getAttribute("type"); - if (type === "checkbox" || type === "radio") { - isProp = true; - } - } - if (node.nodeName === "option" && key === "selected") { - isProp = true; - } - if (key === "disabled" && DISABLED_TAGS.indexOf(node.nodeName) > -1) { - isProp = true; - } - if ((key === "readonly" && node.nodeName === "input") || - node.nodeName === "textarea") { - isProp = true; - } - if (isProp) { - props.push(`${key}: _${val}`); - } - } - for (let i = 0; i < attributes.length; i++) { - let name = attributes[i].name; - const value = attributes[i].textContent; - // regular attributes - if (!name.startsWith("t-") && - !node.getAttribute("t-attf-" + name)) { - const attID = ctx.generateID(); - ctx.addLine(`var _${attID} = '${value}';`); - if (!name.match(/^[a-zA-Z]+$/)) { - // attribute contains 'non letters' => we want to quote it - name = '"' + name + '"'; - } - attrs.push(`${name}: _${attID}`); - handleBooleanProps(name, attID); - } - // dynamic attributes - if (name.startsWith("t-att-")) { - let attName = name.slice(6); - let formattedValue = ctx.formatExpression(ctx.getValue(value)); - if (formattedValue[0] === "{" && - formattedValue[formattedValue.length - 1] === "}") { - formattedValue = `this.utils.objectToAttrString(${formattedValue})`; - } - const attID = ctx.generateID(); - if (!attName.match(/^[a-zA-Z]+$/)) { - // attribute contains 'non letters' => we want to quote it - attName = '"' + attName + '"'; - } - // we need to combine dynamic with non dynamic attributes: - // class="a" t-att-class="'yop'" should be rendered as class="a yop" - const attValue = node.getAttribute(attName); - if (attValue) { - const attValueID = ctx.generateID(); - ctx.addLine(`var _${attValueID} = ${formattedValue};`); - formattedValue = `'${attValue}' + (_${attValueID} ? ' ' + _${attValueID} : '')`; - const attrIndex = attrs.findIndex(att => att.startsWith(attName + ":")); - attrs.splice(attrIndex, 1); - } - ctx.addLine(`var _${attID} = ${formattedValue};`); - attrs.push(`${attName}: _${attID}`); - handleBooleanProps(attName, attID); - } - if (name.startsWith("t-attf-")) { - let attName = name.slice(7); - if (!attName.match(/^[a-zA-Z]+$/)) { - // attribute contains 'non letters' => we want to quote it - attName = '"' + attName + '"'; - } - const formattedExpr = value.replace(/\{\{.*?\}\}/g, s => "${" + ctx.formatExpression(s.slice(2, -2)) + "}"); - const attID = ctx.generateID(); - let staticVal = node.getAttribute(attName); - if (staticVal) { - ctx.addLine(`var _${attID} = '${staticVal} ' + \`${formattedExpr}\`;`); - } - else { - ctx.addLine(`var _${attID} = \`${formattedExpr}\`;`); - } - attrs.push(`${attName}: _${attID}`); - } - // t-att= attributes - if (name === "t-att") { - let id = ctx.generateID(); - ctx.addLine(`var _${id} = ${ctx.formatExpression(value)};`); - tattrs.push(id); - } - } - let nodeID = ctx.generateID(); - let nodeKey = node.getAttribute("t-key"); - if (nodeKey) { - nodeKey = ctx.formatExpression(nodeKey); - } - else { - nodeKey = nodeID; - } - const parts = [`key:${nodeKey}`]; - if (attrs.length + tattrs.length > 0) { - parts.push(`attrs:{${attrs.join(",")}}`); - } - if (props.length > 0) { - parts.push(`props:{${props.join(",")}}`); - } - if (withHandlers) { - parts.push(`on:{}`); - } - ctx.addLine(`var c${nodeID} = [], p${nodeID} = {${parts.join(",")}};`); - for (let id of tattrs) { - ctx.addIf(`_${id} instanceof Array`); - ctx.addLine(`p${nodeID}.attrs[_${id}[0]] = _${id}[1];`); - ctx.addElse(); - ctx.addLine(`for (let key in _${id}) {`); - ctx.indent(); - ctx.addLine(`p${nodeID}.attrs[key] = _${id}[key];`); - ctx.dedent(); - ctx.addLine(`}`); - ctx.closeIf(); - } - ctx.addLine(`var vn${nodeID} = h('${node.nodeName}', p${nodeID}, c${nodeID});`); - if (ctx.parentNode) { - ctx.addLine(`c${ctx.parentNode}.push(vn${nodeID});`); - } - return nodeID; - } - _compileChildren(node, ctx) { - if (node.childNodes.length > 0) { - for (let child of Array.from(node.childNodes)) { - this._compileNode(child, ctx); - } - } - } - } /** * Owl QWeb Directives @@ -2227,6 +2122,7 @@ * - t-ref * - t-transition * - t-widget/t-props/t-keepalive + * - t-mounted */ //------------------------------------------------------------------------------ // t-on @@ -2261,12 +2157,10 @@ QWeb.addDirective({ name: "ref", priority: 95, - atNodeCreation({ ctx, nodeID, value }) { + atNodeCreation({ ctx, nodeID, value, addNodeHook }) { const refKey = `ref${ctx.generateID()}`; ctx.addLine(`const ${refKey} = ${ctx.formatExpression(value)}`); - ctx.addLine(`p${nodeID}.hook = { - create: (_, n) => context.refs[${refKey}] = n.elm, - };`); + addNodeHook("create", `context.refs[${refKey}] = n.elm;`); } }); //------------------------------------------------------------------------------ @@ -2335,24 +2229,163 @@ QWeb.addDirective({ name: "transition", priority: 96, - atNodeCreation({ ctx, value, nodeID }) { + atNodeCreation({ ctx, value, addNodeHook }) { let name = value; - ctx.addLine(`p${nodeID}.hook = { - create: (_, n) => { - this.utils.transitionCreate(n.elm, '${name}'); - }, - insert: vn => { - this.utils.transitionInsert(vn.elm, '${name}'); - }, - remove: (vn, rm) => { - this.utils.transitionRemove(vn.elm, '${name}', rm); - } - };`); + const hooks = { + create: `this.utils.transitionCreate(n.elm, '${name}');`, + insert: `this.utils.transitionInsert(vn.elm, '${name}');`, + remove: `this.utils.transitionRemove(vn.elm, '${name}', rm);` + }; + for (let hookName in hooks) { + addNodeHook(hookName, hooks[hookName]); + } } }); //------------------------------------------------------------------------------ // t-widget //------------------------------------------------------------------------------ + /** + * The t-widget directive is certainly a complicated and hard to maintain piece + * of code. To help you, fellow developer, if you have to maintain it, I offer + * you this advice: Good luck... + * + * Since it is not 'direct' code, but rather code that generates other code, it + * is not easy to understand. To help you, here is a detailed and commented + * explanation of the code generated by the t-widget directive for the following + * situation: + * ```xml + * + * ``` + * + * ```js + * // this is the virtual node representing the parent div + * let c1 = [], p1 = { key: 1 }; + * var vn1 = h("div", p1, c1); + * + * // t-widget directive: we start by evaluating the expression given by t-key: + * let key5 = "somestring"; + * + * // We keep the index of the position of the widget in the closure. We push + * // null to reserve the slot, and will replace it later by the widget vnode, + * // when it will be ready (do not forget that preparing/rendering a widget is + * // asynchronous) + * let _2_index = c1.length; + * c1.push(null); + * + * // def3 is the deferred that will contain later either the new widget + * // creation, or the props update... + * let def3; + * + * // this is kind of tricky: we need here to find if the widget was already + * // created by a previous rendering. This is done by checking the internal + * // `cmap` (children map) of the parent widget: it maps keys to widget ids, + * // and, then, if there is an id, we look into the children list to get the + * // instance + * let w4 = + * key5 in context.__owl__.cmap + * ? context.__owl__.children[context.__owl__.cmap[key5]] + * : false; + * + * // we evaluate here the props given to the component. It is done here to be + * // able to easily reference it later, and also, it might be an expensive + * // computation, so it is certainly better to do it only once + * let props4 = { flag: context["state"].flag }; + * + * // If we have a widget, currently rendering, but not ready yet, and which was + * // rendered with different props, we do not want to wait for it to be ready, + * // then update it. We simply destroy it, and start anew. + * if ( + * w4 && + * w4.__owl__.renderPromise && + * !w4.__owl__.isStarted && + * props4 !== w4.__owl__.renderProps + * ) { + * w4.destroy(); + * w4 = false; + * } + * + * if (!w4) { + * // in this situation, we need to create a new widget. First step is + * // to get a reference to the class, then create an instance with + * // current context as parent, and the props. + * let W4 = context.widgets["child"]; + * if (!W4) { + * throw new Error("Cannot find the definition of widget 'child'"); + * } + * w4 = new W4(owner, props4); + * + * // Whenever we rerender the parent widget, we need to be sure that we + * // are able to find the widget instance. To do that, we register it to + * // the parent cmap (children map). Note that the 'template' key is + * // used here, since this is what identify the widget from the template + * // perspective. + * context.__owl__.cmap[key5] = w4.__owl__.id; + * + * // _prepare is called, to basically call willStart, then render the + * // widget + * def3 = w4._prepare(); + * + * def3 = def3.then(vnode => { + * // we create here a virtual node for the parent (NOT the widget). This + * // means that the vdom of the parent will be stopped here, and from + * // the parent's perspective, it simply is a vnode with no children. + * // However, it shares the same dom element with the component root + * // vnode. + * let pvnode = h(vnode.sel, { key: key5 }); + * + * // we add hooks to the parent vnode so we can interact with the new + * // widget at the proper time + * pvnode.data.hook = { + * insert(vn) { + * // the _mount method will patch the widget vdom into the elm vn.elm, + * // then call the mounted hooks. However, suprisingly, the snabbdom + * // patch method actually replace the elm by a new elm, so we need + * // to synchronise the pvnode elm with the resulting elm + * let nvn = w4._mount(vnode, vn.elm); + * pvnode.elm = nvn.elm; + * }, + * remove() { + * // apparently, in some cases, it is necessary to call the destroy + * // method here + * w4.destroy(); + * }, + * destroy() { + * // and here... + * w4.destroy(); + * } + * }; + * // the pvnode is inserted at the correct position in the div's children + * c1[_2_index] = pvnode; + * + * // we keep here a reference to the parent vnode (representing the + * // widget, so we can reuse it later whenever we update the widget + * w4.__owl__.pvnode = pvnode; + * }); + * } else { + * // this is the 'update' path of the directive. + * // the call to _updateProps is the actual widget update + * def3 = w4._updateProps(props4, extra.forceUpdate, extra.patchQueue); + * def3 = def3.then(() => { + * // if widget was destroyed in the meantime, we do nothing (so, this + * // means that the parent's element children list will have a null in + * // the widget's position, which will cause the pvnode to be removed + * // when it is patched. + * if (w4.__owl__.isDestroyed) { + * return; + * } + * // like above, we register the pvnode to the children list, so it + * // will not be patched out of the dom. + * let pvnode = w4.__owl__.pvnode; + * c1[_2_index] = pvnode; + * }); + * } + * + * // we register the deferred here so the parent can coordinate its patch operation + * // with all the children. + * extra.promises.push(def3); + * return vn1; + * ``` + */ QWeb.addDirective({ name: "widget", extraNames: ["props", "keepalive"], @@ -2395,35 +2428,6 @@ : ctx.inLoop ? `String(-${widgetID} - i)` : String(widgetID); - ctx.addLine(`let w${widgetID} = ${templateID} in context.__owl__.cmap ? context.__owl__.children[context.__owl__.cmap[${templateID}]] : false;`); - ctx.addLine(`let props${widgetID} = ${props || "{}"};`); - ctx.addLine(`let isNew${widgetID} = !w${widgetID};`); - // check if we can reuse current rendering promise - ctx.addIf(`w${widgetID} && w${widgetID}.__owl__.renderPromise`); - ctx.addIf(`w${widgetID}.__owl__.isStarted`); - ctx.addLine(`def${defID} = w${widgetID}._updateProps(props${widgetID}, extra.forceUpdate, extra.patchQueue);`); - ctx.addElse(); - ctx.addLine(`isNew${widgetID} = true`); - ctx.addIf(`props${widgetID} === w${widgetID}.__owl__.renderProps`); - ctx.addLine(`def${defID} = w${widgetID}.__owl__.renderPromise;`); - ctx.addElse(); - ctx.addLine(`w${widgetID}.destroy();`); - ctx.addLine(`w${widgetID} = false`); - ctx.closeIf(); - ctx.closeIf(); - ctx.closeIf(); - ctx.addIf(`!def${defID}`); - ctx.addIf(`w${widgetID}`); - ctx.addLine(`def${defID} = w${widgetID}._updateProps(props${widgetID}, extra.forceUpdate, extra.patchQueue);`); - ctx.addElse(); - ctx.addLine(`w${widgetID} = new context.widgets['${value}'](owner, props${widgetID});`); - ctx.addLine(`context.__owl__.cmap[${templateID}] = w${widgetID}.__owl__.id;`); - for (let [event, method] of events) { - ctx.addLine(`w${widgetID}.on('${event}', owner, owner['${method}'])`); - } - ctx.addLine(`def${defID} = w${widgetID}._prepare();`); - ctx.closeIf(); - ctx.closeIf(); let ref = node.getAttribute("t-ref"); let refExpr = ""; let refKey = ""; @@ -2470,18 +2474,69 @@ const styleCode = styleExpr ? `vn.elm.style = ${styleExpr}` : ""; createHook = `vnode.data.hook = {create(_, vn){${classCode}${styleCode}}};`; } - ctx.addIf(`isNew${widgetID}`); - ctx.addLine(`def${defID} = def${defID}.then(vnode=>{${createHook}let pvnode=h(vnode.sel, {key: ${templateID}});c${ctx.parentNode}[_${dummyID}_index]=pvnode;pvnode.data.hook = {insert(vn){let nvn=w${widgetID}._mount(vnode, vn.elm);pvnode.elm=nvn.elm;${refExpr}},remove(){${finalizeWidgetCode}},destroy(){${finalizeWidgetCode}}}; w${widgetID}.__owl__.pvnode = pvnode;});`); + ctx.addLine(`let w${widgetID} = ${templateID} in context.__owl__.cmap ? context.__owl__.children[context.__owl__.cmap[${templateID}]] : false;`); + ctx.addLine(`let props${widgetID} = ${props || "{}"};`); + ctx.addIf(`w${widgetID} && w${widgetID}.__owl__.renderPromise && !w${widgetID}.__owl__.vnode && props${widgetID} !== w${widgetID}.__owl__.renderProps`); + ctx.addLine(`w${widgetID}.destroy();`); + ctx.addLine(`w${widgetID} = false`); + ctx.closeIf(); + ctx.addIf(`!w${widgetID}`); + // new widget + ctx.addLine(`let W${widgetID} = context.widgets['${value}'];`); + // maybe only do this in dev mode... + ctx.addLine(`if (!W${widgetID}) {throw new Error(\`Cannot find the definition of widget "${value}"\`)}`); + ctx.addLine(`w${widgetID} = new W${widgetID}(owner, props${widgetID});`); + ctx.addLine(`context.__owl__.cmap[${templateID}] = w${widgetID}.__owl__.id;`); + for (let [event, method] of events) { + ctx.addLine(`w${widgetID}.on('${event}', owner, owner['${method}'])`); + } + ctx.addLine(`def${defID} = w${widgetID}._prepare();`); + ctx.addLine(`def${defID} = def${defID}.then(vnode=>{${createHook}let pvnode=h(vnode.sel, {key: ${templateID}, hook: {insert(vn){let nvn=w${widgetID}._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}},remove(){${finalizeWidgetCode}},destroy(){${finalizeWidgetCode}}}});c${ctx.parentNode}[_${dummyID}_index]=pvnode;w${widgetID}.__owl__.pvnode = pvnode;});`); ctx.addElse(); - ctx.addLine(`def${defID} = def${defID}.then(()=>{if (w${widgetID}.__owl__.isDestroyed) {return};${tattStyle ? `w${widgetID}.el.style=${tattStyle};` : ""}${updateClassCode}let vnode;if (!w${widgetID}.__owl__.vnode){vnode=w${widgetID}.__owl__.pvnode} else { vnode=h(w${widgetID}.__owl__.vnode.sel, {key: ${templateID}});vnode.elm=w${widgetID}.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w${widgetID}.el,a.elm);a.elm=w${widgetID}.el;w${widgetID}.__mount();},remove(){${finalizeWidgetCode}}, destroy() {${finalizeWidgetCode}}}}c${ctx.parentNode}[_${dummyID}_index]=vnode;});`); + // need to update widget + ctx.addLine(`def${defID} = w${widgetID}._updateProps(props${widgetID}, extra.forceUpdate, extra.patchQueue);`); + let keepAliveCode = ""; + if (keepAlive) { + keepAliveCode = `pvnode.data.hook.insert = vn => {vn.elm.parentNode.replaceChild(w${widgetID}.el,vn.elm);vn.elm=w${widgetID}.el;w${widgetID}.__mount();};`; + } + ctx.addLine(`def${defID} = def${defID}.then(()=>{if (w${widgetID}.__owl__.isDestroyed) {return};${tattStyle ? `w${widgetID}.el.style=${tattStyle};` : ""}${updateClassCode}let pvnode=w${widgetID}.__owl__.pvnode;${keepAliveCode}c${ctx.parentNode}[_${dummyID}_index]=pvnode;});`); ctx.closeIf(); ctx.addLine(`extra.promises.push(def${defID});`); - if (node.hasAttribute("t-if") || node.hasAttribute("t-else")) { + if (node.hasAttribute("t-if") || + node.hasAttribute("t-else") || + node.hasAttribute("t-elif")) { ctx.closeIf(); } return true; } }); + //------------------------------------------------------------------------------ + // t-mounted + //------------------------------------------------------------------------------ + QWeb.addDirective({ + name: "mounted", + priority: 97, + atNodeCreation({ ctx, fullName, value, nodeID, addNodeHook }) { + ctx.rootContext.shouldDefineOwner = true; + const eventName = fullName.slice(5); + if (!eventName) { + throw new Error("Missing event name with t-on directive"); + } + let extraArgs; + let handler = value.replace(/\(.*\)/, function (args) { + extraArgs = args.slice(1, -1); + return ""; + }); + let error = `(function () {throw new Error('Missing handler \\'' + '${handler}' + \`\\' when evaluating template '${ctx.templateName.replace(/`/g, "'")}'\`)})()`; + if (extraArgs) { + ctx.addLine(`extra.mountedHandlers[${nodeID}] = (context['${handler}'] || ${error}).bind(owner, ${ctx.formatExpression(extraArgs)});`); + } + else { + ctx.addLine(`extra.mountedHandlers[${nodeID}] = extra.mountedHandlers[${nodeID}] || (context['${handler}'] || ${error}).bind(owner);`); + } + addNodeHook("insert", `if (context.__owl__.isMounted) { extra.mountedHandlers[${nodeID}](); }`); + } + }); class Store extends EventBus { constructor(config, options = {}) { @@ -2569,6 +2624,7 @@ } return 0; } + let nextID$1 = 1; function connect(mapStateToProps, options = {}) { let hashFunction = options.hashFunction || null; if (!hashFunction) { @@ -2596,7 +2652,7 @@ }; } return function (Comp) { - return class extends Comp { + const Result = class extends Comp { constructor(parent, props) { const env = parent instanceof Component ? parent.env : parent; const ownProps = Object.assign({}, props || {}); @@ -2658,9 +2714,115 @@ return super._updateProps(mergedProps, forceUpdate, patchQueue); } }; + // we assign here a unique name to the resulting anonymous class. + // this is necessary for Owl to be able to properly deduce templates. + // Otherwise, all connected components would have the same name, and then + // each component after the first will necessarily have the same template. + let name = `ConnectedComponent${nextID$1++}`; + Object.defineProperty(Result, "name", { value: name }); + return Result; }; } + /** + * Owl Utils + * + * We have here a small collection of utility functions: + * + * - whenReady + * - loadJS + * - loadTemplates + * - escape + * - debounce + */ + function whenReady(fn) { + if (document.readyState === "complete") { + fn(); + } + else { + document.addEventListener("DOMContentLoaded", fn, false); + } + } + const loadedScripts = {}; + function loadJS(url) { + if (url in loadedScripts) { + return loadedScripts[url]; + } + const promise = new Promise(function (resolve, reject) { + const script = document.createElement("script"); + script.type = "text/javascript"; + script.src = url; + script.onload = function () { + resolve(); + }; + script.onerror = function () { + reject(`Error loading file '${url}'`); + }; + const head = document.head || document.getElementsByTagName("head")[0]; + head.appendChild(script); + }); + loadedScripts[url] = promise; + return promise; + } + async function loadTemplates(url) { + const result = await fetch(url); + if (!result.ok) { + throw new Error("Error while fetching xml templates"); + } + let templates = await result.text(); + templates = templates.replace(//g, ""); + return templates; + } + function escape(str) { + if (str === undefined) { + return ""; + } + if (typeof str === "number") { + return String(str); + } + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, "'") + .replace(/`/g, "`"); + } + /** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. If `immediate` is passed, trigger the function on the + * leading edge, instead of the trailing. + * + * Inspired by https://davidwalsh.name/javascript-debounce-function + */ + function debounce(func, wait, immediate) { + let timeout; + return function () { + const context = this; + const args = arguments; + function later() { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + } + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + func.apply(context, args); + } + }; + } + + var _utils = /*#__PURE__*/Object.freeze({ + whenReady: whenReady, + loadJS: loadJS, + loadTemplates: loadTemplates, + escape: escape, + debounce: debounce + }); + /** * This file is the main file packaged by rollup (see rollup.config.js). From * this file, we export all public owl elements. @@ -2677,9 +2839,9 @@ exports.connect = connect; exports.Store = Store; - exports._version = '0.10.0'; - exports._date = '2019-05-10T10:19:55.109Z'; - exports._hash = 'e04c5b1'; + exports._version = '0.11.0'; + exports._date = '2019-05-17T21:35:18.307Z'; + exports._hash = '6719650'; exports._url = 'https://github.com/odoo/owl'; }(this.owl = this.owl || {})); diff --git a/playground/app.js b/playground/app.js index bf72dff3..5c3ec3df 100644 --- a/playground/app.js +++ b/playground/app.js @@ -1,5 +1,8 @@ import { SAMPLES } from "./samples.js"; +//------------------------------------------------------------------------------ +// Constants, helpers, utils +//------------------------------------------------------------------------------ let owlJS; async function owlSourceCode() { @@ -58,82 +61,90 @@ while True: sys.exit(0) `; -//------------------------------------------------------------------------------ -// Tabbed editor -//------------------------------------------------------------------------------ -class TabbedEditor extends owl.Component { - constructor() { - super(...arguments); - this.template = "tabbed-editor"; - this.state = { - currentTab: this.props.display.split("|")[0] - }; - this.tabs = { - js: this.props.display.includes("js"), - xml: this.props.display.includes("xml"), - css: this.props.display.includes("css") - }; - } +/** + * Make an iframe, with all the js, css and xml properly injected. + */ +function makeCodeIframe(js, css, xml, errorHandler) { + // check templates + var qweb = new owl.QWeb(); + const sanitizedXML = xml.replace(//g, ""); - mounted() { - this.editor = ace.edit(this.refs.editor); + // will throw error if there is something wrong with xml + qweb.addTemplates(sanitizedXML); - // remove this for xml/css (?) - this.editor.session.setOption("useWorker", false); - this.editor.setValue(this.props[this.state.currentTab], -1); - this.editor.setFontSize("12px"); - this.editor.setTheme("ace/theme/monokai"); - this.editor.session.setMode(MODES[this.state.currentTab]); - const tabSize = this.state.currentTab === "xml" ? 2 : 4; - this.editor.session.setOption("tabSize", tabSize); - this.editor.on("blur", () => { - const editorValue = this.editor.getValue(); - const propsValue = this.props[this.state.currentTab]; - if (editorValue !== propsValue) { - this.trigger("updateCode", { - type: this.state.currentTab, - value: editorValue - }); - } + // create iframe + const iframe = document.createElement("iframe"); + + iframe.onload = () => { + const doc = iframe.contentDocument; + // inject js + const owlScript = doc.createElement("script"); + owlScript.type = "text/javascript"; + owlScript.src = "../owl.js"; + owlScript.addEventListener("load", () => { + const script = doc.createElement("script"); + script.type = "text/javascript"; + const content = `window.TEMPLATES = \`${sanitizedXML}\`\n${js}`; + script.innerHTML = content; + iframe.contentWindow.addEventListener("error", errorHandler); + iframe.contentWindow.addEventListener("unhandledrejection", errorHandler); + setTimeout(function() { + if (iframe.contentWindow) { + iframe.contentWindow.removeEventListener("error", errorHandler); + iframe.contentWindow.removeEventListener( + "unhandledrejection", + errorHandler + ); + } + }, 200); + doc.body.appendChild(script); }); + doc.head.appendChild(owlScript); + + // inject css + const style = document.createElement("style"); + style.innerHTML = css; + doc.head.appendChild(style); + }; + return iframe; +} + +/** + * Make a zip file containing a functioning application + */ +async function makeApp(js, css, xml) { + await owl.utils.loadJS("libs/jszip.min.js"); + + const zip = new JSZip(); + const processedJS = js + .split("\n") + .map(l => (l === "" ? "" : " " + l)) + .join("\n"); + + const JS = `async function startApp() { + // Loading templates + let TEMPLATES; + try { + TEMPLATES = await owl.utils.loadTemplates('app.xml'); + } catch(e) { + document.write(\`This app requires a static server. If you have python installed, try 'python app.py'\`); + return; } - patched() { - if (this.editor) { - window.dispatchEvent(new Event("resize")); - this.editor.setValue(this.props[this.state.currentTab], -1); - } - } + // Application code +${processedJS} +} - willUnmount() { - this.editor.destroy(); - delete this.editor; - } +// wait for DOM ready before starting +owl.utils.whenReady(startApp);`; - setTab(tab) { - this.editor.setValue(this.props[tab], -1); - - const mode = MODES[tab]; - this.editor.session.setMode(mode); - const tabSize = tab === "xml" ? 2 : 4; - this.editor.session.setOption("tabSize", tabSize); - this.state.currentTab = tab; - } - - onMouseDown(ev) { - if (ev.target.tagName === "DIV") { - let y = ev.clientY; - const resizer = ev => { - const delta = ev.clientY - y; - y = ev.clientY; - this.trigger("updatePanelHeight", { delta }); - }; - document.body.addEventListener("mousemove", resizer); - document.body.addEventListener("mouseup", () => { - document.body.removeEventListener("mousemove", resizer); - }); - } - } + zip.file("app.js", JS); + zip.file("app.css", css); + zip.file("app.py", APP_PY); + zip.file("app.xml", xml); + zip.file("index.html", DEFAULT_HTML); + zip.file("owl.js", owlSourceCode()); + return zip.generateAsync({ type: "blob" }); } //------------------------------------------------------------------------------ @@ -142,7 +153,6 @@ class TabbedEditor extends owl.Component { class App extends owl.Component { constructor(...args) { super(...args); - this.template = "playground"; this.version = owl._version; this.SAMPLES = SAMPLES; this.widgets = { TabbedEditor }; @@ -157,6 +167,10 @@ class App extends owl.Component { leftPaneWidth: Math.ceil(window.innerWidth / 2), topPanelHeight: null }; + + this.toggleLayout = owl.utils.debounce(this.toggleLayout, 250, true); + this.runCode = owl.utils.debounce(this.runCode, 250, true); + this.downloadCode = owl.utils.debounce(this.downloadCode, 250, true); } displayError(error) { @@ -169,61 +183,26 @@ class App extends owl.Component { } } - async runCode() { + runCode() { this.state.displayWelcome = false; - // check templates - var qweb = new owl.QWeb(); - var error = false; - const sanitizedXML = this.state.xml.replace(//g, ""); + let subiframe; + let error = false; + const errorHandler = e => this.displayError(e.message || e.reason.message); try { - qweb.loadTemplates(sanitizedXML); + const { js, css, xml } = this.state; + subiframe = makeCodeIframe(js, css, xml, errorHandler); } catch (e) { + //probably problem with the templates error = e; } - if (error) { this.displayError(error.message); return; } else { this.state.error = false; } - - // create iframe - const iframe = document.createElement("iframe"); - - iframe.onload = () => { - const doc = iframe.contentDocument; - // inject js - const owlScript = doc.createElement("script"); - owlScript.type = "text/javascript"; - owlScript.src = "../owl.js"; - owlScript.addEventListener("load", () => { - const script = doc.createElement("script"); - script.type = "text/javascript"; - const content = `window.TEMPLATES = \`${sanitizedXML}\`\n${ - this.state.js - }`; - script.innerHTML = content; - const errorHandler = e => this.displayError(e.message || e.reason.message); - iframe.contentWindow.addEventListener("error", errorHandler); - iframe.contentWindow.addEventListener("unhandledrejection", errorHandler); - setTimeout(function() { - if (iframe.contentWindow) { - iframe.contentWindow.removeEventListener("error", errorHandler); - iframe.contentWindow.removeEventListener("unhandledrejection", errorHandler); - } - }, 200); - doc.body.appendChild(script); - }); - doc.head.appendChild(owlScript); - - // inject css - const style = document.createElement("style"); - style.innerHTML = this.state.css; - doc.head.appendChild(style); - }; this.refs.content.innerHTML = ""; - this.refs.content.appendChild(iframe); + this.refs.content.appendChild(subiframe); } setSample(ev) { @@ -280,38 +259,91 @@ class App extends owl.Component { } async downloadCode() { + const { js, css, xml } = this.state; + const content = await makeApp(js, css, xml); await owl.utils.loadJS("libs/FileSaver.min.js"); - await owl.utils.loadJS("libs/jszip.min.js"); - - const zip = new JSZip(); - - const JS = `async function startApp() { - // Loading templates - let TEMPLATES; - try { - TEMPLATES = await owl.utils.loadTemplates('app.xml'); - } catch(e) { - document.write(\`This app requires a static server. If you have python installed, try 'python app.py'\`); - return; + saveAs(content, "app.zip"); } - - // Application code -${this.state.js.split('\n').map(l => l === '' ? '' : ' ' + l).join('\n')} } -// wait for DOM ready before starting -owl.utils.whenReady(startApp);`; +//------------------------------------------------------------------------------ +// Tabbed editor +//------------------------------------------------------------------------------ +class TabbedEditor extends owl.Component { + constructor(parent, props) { + super(parent, props); + this.state = { + currentTab: props.js ? "js" : props.xml ? "xml" : "css" + }; + this.setTab = owl.utils.debounce(this.setTab, 250, true); - zip.file("app.js", JS); - zip.file("app.css", this.state.css); - zip.file("app.py", APP_PY); - zip.file("app.xml", this.state.xml); - zip.file("index.html", DEFAULT_HTML); - zip.file("owl.js", owlSourceCode()); - zip.generateAsync({ type: "blob" }).then(function(content) { - saveAs(content, "app.zip"); + this.sessions = {}; + for (let tab of ["js", "xml", "css"]) { + if (props[tab]) { + this.sessions[tab] = new ace.EditSession(props[tab], MODES[tab]); + this.sessions[tab].setOption("useWorker", false); + const tabSize = tab === "xml" ? 2 : 4; + this.sessions[tab].setOption("tabSize", tabSize); + this.sessions[tab].setUndoManager(new ace.UndoManager()); + } + } + } + + mounted() { + this.editor = ace.edit(this.refs.editor); + + this.editor.setValue(this.props[this.state.currentTab], -1); + this.editor.setFontSize("12px"); + this.editor.setTheme("ace/theme/monokai"); + this.editor.setSession(this.sessions[this.state.currentTab]); + const tabSize = this.state.currentTab === "xml" ? 2 : 4; + this.editor.session.setOption("tabSize", tabSize); + this.editor.on("blur", () => { + const editorValue = this.editor.getValue(); + const propsValue = this.props[this.state.currentTab]; + if (editorValue !== propsValue) { + this.trigger("updateCode", { + type: this.state.currentTab, + value: editorValue + }); + } }); } + + patched() { + const session = this.sessions[this.state.currentTab]; + session.setValue(this.props[this.state.currentTab], -1); + this.editor.setSession(session); + } + + willUnmount() { + this.editor.destroy(); + delete this.editor; + } + + setTab(tab) { + if (this.state.currentTab !== tab) { + this.state.currentTab = tab; + const session = this.sessions[this.state.currentTab]; + session.doc.setValue(this.props[tab], -1); + this.editor.setSession(session); + } + } + + onMouseDown(ev) { + if (ev.target.tagName === "DIV") { + let y = ev.clientY; + const resizer = ev => { + const delta = ev.clientY - y; + y = ev.clientY; + this.trigger("updatePanelHeight", { delta }); + }; + document.body.addEventListener("mousemove", resizer); + document.body.addEventListener("mouseup", () => { + document.body.removeEventListener("mousemove", resizer); + }); + } + } } //------------------------------------------------------------------------------ diff --git a/playground/playground.css b/playground/playground.css index bbe61778..f8900e22 100644 --- a/playground/playground.css +++ b/playground/playground.css @@ -22,7 +22,7 @@ body { display: grid; height: 100%; width: 100%; - grid-template-columns: auto 6px 100%; + grid-template-columns: auto 8px 100%; } /* LEFT BAR ****************************************/ @@ -115,6 +115,7 @@ body { width: 34px; text-align: center; border-bottom: 2px solid transparent; + text-transform: uppercase; } .tab.active { diff --git a/playground/samples.js b/playground/samples.js index 3a07cf12..36dd3118 100644 --- a/playground/samples.js +++ b/playground/samples.js @@ -1,7 +1,6 @@ const CLICK_COUNTER = `class ClickCounter extends owl.Component { constructor() { super(...arguments); - this.template = "clickcounter"; this.state = { value: 0 }; } @@ -16,7 +15,7 @@ counter.mount(document.body); `; const CLICK_COUNTER_XML = ` - `; @@ -29,7 +28,6 @@ const CLICK_COUNTER_CSS = `button { const CLICK_COUNTER_ESNEXT = `// This example will not work if your browser does not support ESNext class fields class ClickCounter extends owl.Component { - template = "clickcounter"; state = { value: 0 }; increment() { @@ -45,7 +43,6 @@ counter.mount(document.body); const WIDGET_COMPOSITION = `class ClickCounter extends owl.Component { constructor(parent, props) { super(parent, props); - this.template = "clickcounter"; this.state = { value: props.initialState || 0 }; } @@ -59,7 +56,6 @@ let nextId = 1; class App extends owl.Component { constructor() { super(...arguments); - this.template = "app"; this.state = { counters: [] } this.widgets = { ClickCounter }; } @@ -75,11 +71,11 @@ app.mount(document.body); `; const WIDGET_COMPOSITION_XML = ` - -
+
@@ -97,7 +93,6 @@ const WIDGET_COMPOSITION_CSS = `button { const ANIMATION = `// This example will not work if your browser does not support ESNext class fields class App extends owl.Component { - template = "app"; state = {flag: 0}; toggle() { @@ -111,7 +106,7 @@ app.mount(document.body); `; const ANIMATION_XML = ` -
+
@@ -152,7 +147,6 @@ const ANIMATION_CSS = `button { const LIFECYCLE_DEMO = `class HookWidget extends owl.Component { constructor() { super(...arguments); - this.template = "demo.hookwidget"; this.state = { n: 0 }; console.log("constructor"); } @@ -183,7 +177,6 @@ class ParentWidget extends owl.Component { constructor() { super(...arguments); this.widgets = { HookWidget }; - this.template = "demo.parentwidget"; this.state = { n: 0, flag: true }; } increment() { @@ -200,14 +193,14 @@ widget.mount(document.body); `; const LIFECYCLE_DEMO_XML = ` -
+
-
-
Demo Sub Widget. Props: . State: . (click on me to update me)
+
+
Demo Sub Widget. Props: . State: . (click on me to update me)
`; const BENCHMARK_APP = `//------------------------------------------------------------------------------ @@ -241,7 +234,6 @@ for (let i = 1; i < 16000; i++) { class Counter extends owl.Component { constructor(parent, props) { super(parent, props); - this.template = "counter"; this.state = { counter: props.initialState || 0 }; } @@ -256,7 +248,6 @@ class Counter extends owl.Component { class Message extends owl.Component { constructor() { super(...arguments); - this.template = "message"; this.widgets = { Counter }; } @@ -273,7 +264,6 @@ class Message extends owl.Component { class App extends owl.Component { constructor() { super(...arguments); - this.template = "root"; this.widgets = { Message }; this.state = { messages: messages.slice(0, 10) }; } @@ -351,7 +341,7 @@ const BENCHMARK_APP_CSS = `.main { }`; const BENCHMARK_APP_XML = ` -
+
@@ -374,14 +364,14 @@ const BENCHMARK_APP_XML = `
-
+
-
+
Value: @@ -477,7 +467,6 @@ function makeStore() { // TodoItem //------------------------------------------------------------------------------ class TodoItem extends owl.Component { - template = "todoitem"; state = { isEditing: false }; removeTodo() { @@ -490,11 +479,12 @@ class TodoItem extends owl.Component { async editTodo() { this.state.isEditing = true; - setTimeout(() => { - this.refs.input.value = ""; - this.refs.input.focus(); - this.refs.input.value = this.props.title; - }); + } + + focusInput() { + this.refs.input.value = ""; + this.refs.input.focus(); + this.refs.input.value = this.props.title; } handleKeyup(ev) { @@ -535,7 +525,6 @@ function mapStateToProps(state) { } class TodoApp extends owl.Component { - template = "todoapp"; widgets = { TodoItem }; state = { filter: "all" }; @@ -602,7 +591,7 @@ app.mount(document.body); `; const TODO_APP_STORE_XML = ` -
+

todos

@@ -640,7 +629,7 @@ const TODO_APP_STORE_XML = `
-
  • +
  • - +
  • `; @@ -1034,39 +1023,25 @@ html .clear-completed:active { } `; -const RESPONSIVE = `class Navbar extends owl.Component { - template="navbar"; -} +const RESPONSIVE = `class Navbar extends owl.Component {} class ControlPanel extends owl.Component { - template="controlpanel"; widgets = { MobileSearchView }; } class FormView extends owl.Component { - template="formview"; widgets = { AdvancedWidget }; } -class AdvancedWidget extends owl.Component { - template="advancedwidget"; -} +class AdvancedWidget extends owl.Component {} -class Chatter extends owl.Component { - template="chatter"; -} +class Chatter extends owl.Component {} -class MobileSearchView extends owl.Component { - template="mobilesearchview"; -} +class MobileSearchView extends owl.Component {} class App extends owl.Component { - constructor() { - super(...arguments); - this.template = "app"; - this.widgets = { Navbar, ControlPanel, FormView, Chatter }; - } + widgets = { Navbar, ControlPanel, FormView, Chatter }; } function isMobile() { @@ -1081,39 +1056,39 @@ const env = { const app = new App(env); app.mount(document.body); -window.addEventListener( - "resize", - owl.utils.debounce(function() { - const _isMobile = isMobile(); - if (_isMobile !== env.isMobile) { - app.updateEnv({ - isMobile: _isMobile - }); - } - }, 20) -);`; +function updateEnv() { + const _isMobile = isMobile(); + if (_isMobile !== env.isMobile) { + app.updateEnv({ + isMobile: _isMobile + }); + } +} + +window.addEventListener("resize", owl.utils.debounce(updateEnv, 20)); +`; const RESPONSIVE_XML = ` - + -
    +

    controlpanel

    -
    +

    formview

    -
    +

    Chatter

    Message
    -
    MOBILE searchview
    +
    MOBILE searchview
    -
    +
    @@ -1128,7 +1103,7 @@ const RESPONSIVE_XML = `
    -
    +
    This widget is only created in desktop mode.
    diff --git a/playground/templates.xml b/playground/templates.xml index 30f5b6c1..f7ee67de 100644 --- a/playground/templates.xml +++ b/playground/templates.xml @@ -1,14 +1,16 @@ -
    +
    - JS - XML - CSS + + + + +
    -
    +
    - - + + +
    + - -
    - +