UNPKG

@lumino/virtualdom

Version:
679 lines (676 loc) 24.5 kB
import { ArrayExt } from '@lumino/algorithm'; // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2017, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ /** * @packageDocumentation * @module virtualdom */ /** * A virtual node which represents plain text content. * * #### Notes * User code will not typically create a `VirtualText` node directly. * Instead, the `h()` function will be used to create an element tree. */ class VirtualText { /** * Construct a new virtual text node. * * @param content - The text content for the node. */ constructor(content) { /** * The type of the node. * * This value can be used as a type guard for discriminating the * `VirtualNode` union type. */ this.type = 'text'; this.content = content; } } /** * A virtual node which represents an HTML element. * * #### Notes * User code will not typically create a `VirtualElement` node directly. * Instead, the `h()` function will be used to create an element tree. */ class VirtualElement { /** * Construct a new virtual element node. * * @param tag - The element tag name. * * @param attrs - The element attributes. * * @param children - The element children. * * @param renderer - An optional custom renderer for the element. */ constructor(tag, attrs, children, renderer) { /** * The type of the node. * * This value can be used as a type guard for discriminating the * `VirtualNode` union type. */ this.type = 'element'; this.tag = tag; this.attrs = attrs; this.children = children; this.renderer = renderer; } } /** * DEPRECATED - use VirtualElement with a defined renderer param instead. * This class is provided as a backwards compatibility shim * * A "pass thru" virtual node whose children are managed by a render and an * unrender callback. The intent of this flavor of virtual node is to make * it easy to blend other kinds of virtualdom (eg React) into Phosphor's * virtualdom. * * #### Notes * User code will not typically create a `VirtualElementPass` node directly. * Instead, the `hpass()` function will be used to create an element tree. */ class VirtualElementPass extends VirtualElement { /** * DEPRECATED - use VirtualElement with a defined renderer param instead * * Construct a new virtual element pass thru node. * * @param tag - the tag of the parent element of this node. Once the parent * element is rendered, it will be passed as an argument to * renderer.render * * @param attrs - attributes that will assigned to the * parent element * * @param renderer - an object with render and unrender * functions, each of which should take a single argument of type * HTMLElement and return nothing. If null, the parent element * will be rendered barren without any children. */ constructor(tag, attrs, renderer) { super(tag, attrs, [], renderer || undefined); } } function h(tag) { let attrs = {}; let renderer; let children = []; for (let i = 1, n = arguments.length; i < n; ++i) { // eslint-disable-next-line prefer-rest-params let arg = arguments[i]; if (typeof arg === 'string') { children.push(new VirtualText(arg)); } else if (arg instanceof VirtualText) { children.push(arg); } else if (arg instanceof VirtualElement) { children.push(arg); } else if (arg instanceof Array) { extend(children, arg); } else if ((i === 1 || i === 2) && arg && typeof arg === 'object') { if ('render' in arg) { renderer = arg; } else { attrs = arg; } } } return new VirtualElement(tag, attrs, children, renderer); function extend(array, values) { for (let child of values) { if (typeof child === 'string') { array.push(new VirtualText(child)); } else if (child instanceof VirtualText) { array.push(child); } else if (child instanceof VirtualElement) { array.push(child); } } } } /** * The namespace for the `h` function statics. */ (function (h) { h.a = h.bind(undefined, 'a'); h.abbr = h.bind(undefined, 'abbr'); h.address = h.bind(undefined, 'address'); h.area = h.bind(undefined, 'area'); h.article = h.bind(undefined, 'article'); h.aside = h.bind(undefined, 'aside'); h.audio = h.bind(undefined, 'audio'); h.b = h.bind(undefined, 'b'); h.bdi = h.bind(undefined, 'bdi'); h.bdo = h.bind(undefined, 'bdo'); h.blockquote = h.bind(undefined, 'blockquote'); h.br = h.bind(undefined, 'br'); h.button = h.bind(undefined, 'button'); h.canvas = h.bind(undefined, 'canvas'); h.caption = h.bind(undefined, 'caption'); h.cite = h.bind(undefined, 'cite'); h.code = h.bind(undefined, 'code'); h.col = h.bind(undefined, 'col'); h.colgroup = h.bind(undefined, 'colgroup'); h.data = h.bind(undefined, 'data'); h.datalist = h.bind(undefined, 'datalist'); h.dd = h.bind(undefined, 'dd'); h.del = h.bind(undefined, 'del'); h.dfn = h.bind(undefined, 'dfn'); h.div = h.bind(undefined, 'div'); h.dl = h.bind(undefined, 'dl'); h.dt = h.bind(undefined, 'dt'); h.em = h.bind(undefined, 'em'); h.embed = h.bind(undefined, 'embed'); h.fieldset = h.bind(undefined, 'fieldset'); h.figcaption = h.bind(undefined, 'figcaption'); h.figure = h.bind(undefined, 'figure'); h.footer = h.bind(undefined, 'footer'); h.form = h.bind(undefined, 'form'); h.h1 = h.bind(undefined, 'h1'); h.h2 = h.bind(undefined, 'h2'); h.h3 = h.bind(undefined, 'h3'); h.h4 = h.bind(undefined, 'h4'); h.h5 = h.bind(undefined, 'h5'); h.h6 = h.bind(undefined, 'h6'); h.header = h.bind(undefined, 'header'); h.hr = h.bind(undefined, 'hr'); h.i = h.bind(undefined, 'i'); h.iframe = h.bind(undefined, 'iframe'); h.img = h.bind(undefined, 'img'); h.input = h.bind(undefined, 'input'); h.ins = h.bind(undefined, 'ins'); h.kbd = h.bind(undefined, 'kbd'); h.label = h.bind(undefined, 'label'); h.legend = h.bind(undefined, 'legend'); h.li = h.bind(undefined, 'li'); h.main = h.bind(undefined, 'main'); h.map = h.bind(undefined, 'map'); h.mark = h.bind(undefined, 'mark'); h.meter = h.bind(undefined, 'meter'); h.nav = h.bind(undefined, 'nav'); h.noscript = h.bind(undefined, 'noscript'); h.object = h.bind(undefined, 'object'); h.ol = h.bind(undefined, 'ol'); h.optgroup = h.bind(undefined, 'optgroup'); h.option = h.bind(undefined, 'option'); h.output = h.bind(undefined, 'output'); h.p = h.bind(undefined, 'p'); h.param = h.bind(undefined, 'param'); h.pre = h.bind(undefined, 'pre'); h.progress = h.bind(undefined, 'progress'); h.q = h.bind(undefined, 'q'); h.rp = h.bind(undefined, 'rp'); h.rt = h.bind(undefined, 'rt'); h.ruby = h.bind(undefined, 'ruby'); h.s = h.bind(undefined, 's'); h.samp = h.bind(undefined, 'samp'); h.section = h.bind(undefined, 'section'); h.select = h.bind(undefined, 'select'); h.small = h.bind(undefined, 'small'); h.source = h.bind(undefined, 'source'); h.span = h.bind(undefined, 'span'); h.strong = h.bind(undefined, 'strong'); h.sub = h.bind(undefined, 'sub'); h.summary = h.bind(undefined, 'summary'); h.sup = h.bind(undefined, 'sup'); h.table = h.bind(undefined, 'table'); h.tbody = h.bind(undefined, 'tbody'); h.td = h.bind(undefined, 'td'); h.textarea = h.bind(undefined, 'textarea'); h.tfoot = h.bind(undefined, 'tfoot'); h.th = h.bind(undefined, 'th'); h.thead = h.bind(undefined, 'thead'); h.time = h.bind(undefined, 'time'); h.title = h.bind(undefined, 'title'); h.tr = h.bind(undefined, 'tr'); h.track = h.bind(undefined, 'track'); h.u = h.bind(undefined, 'u'); h.ul = h.bind(undefined, 'ul'); h.var_ = h.bind(undefined, 'var'); h.video = h.bind(undefined, 'video'); h.wbr = h.bind(undefined, 'wbr'); })(h || (h = {})); function hpass(tag) { let attrs = {}; let renderer = null; if (arguments.length === 2) { // eslint-disable-next-line prefer-rest-params const arg = arguments[1]; if ('render' in arg) { renderer = arg; } else { attrs = arg; } } else if (arguments.length === 3) { // eslint-disable-next-line prefer-rest-params attrs = arguments[1]; // eslint-disable-next-line prefer-rest-params renderer = arguments[2]; } else if (arguments.length > 3) { throw new Error('hpass() should be called with 1, 2, or 3 arguments'); } return new VirtualElementPass(tag, attrs, renderer); } /** * The namespace for the virtual DOM rendering functions. */ var VirtualDOM; (function (VirtualDOM) { function realize(node) { return Private.createDOMNode(node); } VirtualDOM.realize = realize; /** * Render virtual DOM content into a host element. * * @param content - The virtual DOM content to render. * * @param host - The host element for the rendered content. * * #### Notes * This renders the delta from the previous rendering. It assumes that * the content of the host element is not manipulated by external code. * * Providing `null` content will clear the rendering. * * Externally modifying the provided content or the host element will * result in undefined rendering behavior. */ function render(content, host) { let oldContent = Private.hostMap.get(host) || []; let newContent = Private.asContentArray(content); Private.hostMap.set(host, newContent); Private.updateContent(host, oldContent, newContent); } VirtualDOM.render = render; })(VirtualDOM || (VirtualDOM = {})); /** * The namespace for the module implementation details. */ var Private; (function (Private) { /** * A weak mapping of host element to virtual DOM content. */ Private.hostMap = new WeakMap(); /** * Cast a content value to a content array. */ function asContentArray(value) { if (!value) { return []; } if (value instanceof Array) { return value; } return [value]; } Private.asContentArray = asContentArray; function createDOMNode(node) { // eslint-disable-next-line prefer-rest-params let host = arguments[1] || null; // eslint-disable-next-line prefer-rest-params const before = arguments[2] || null; if (host) { host.insertBefore(createDOMNode(node), before); } else { // Create a text node for a virtual text node. if (node.type === 'text') { return document.createTextNode(node.content); } // Create the HTML element with the specified tag. host = document.createElement(node.tag); // Add the attributes for the new element. addAttrs(host, node.attrs); if (node.renderer) { node.renderer.render(host, { attrs: node.attrs, children: node.children }); return host; } // Recursively populate the element with child content. for (let i = 0, n = node.children.length; i < n; ++i) { createDOMNode(node.children[i], host); } } return host; } Private.createDOMNode = createDOMNode; /** * Update a host element with the delta of the virtual content. * * This is the core "diff" algorithm. There is no explicit "patch" * phase. The host is patched at each step as the diff progresses. */ function updateContent(host, oldContent, newContent) { // Bail early if the content is identical. if (oldContent === newContent) { return; } // Collect the old keyed elems into a mapping. let oldKeyed = collectKeys(host, oldContent); // Create a copy of the old content which can be modified in-place. let oldCopy = oldContent.slice(); // Update the host with the new content. The diff always proceeds // forward and never modifies a previously visited index. The old // copy array is modified in-place to reflect the changes made to // the host children. This causes the stale nodes to be pushed to // the end of the host node and removed at the end of the loop. let currElem = host.firstChild; let newCount = newContent.length; for (let i = 0; i < newCount; ++i) { // If the old content is exhausted, create a new node. if (i >= oldCopy.length) { createDOMNode(newContent[i], host); continue; } // Lookup the old and new virtual nodes. let oldVNode = oldCopy[i]; let newVNode = newContent[i]; // If both elements are identical, there is nothing to do. if (oldVNode === newVNode) { currElem = currElem.nextSibling; continue; } // Handle the simplest case of in-place text update first. if (oldVNode.type === 'text' && newVNode.type === 'text') { // Avoid spurious updates for performance. if (currElem.textContent !== newVNode.content) { currElem.textContent = newVNode.content; } currElem = currElem.nextSibling; continue; } // If the old or new node is a text node, the other node is now // known to be an element node, so create and insert a new node. if (oldVNode.type === 'text' || newVNode.type === 'text') { ArrayExt.insert(oldCopy, i, newVNode); createDOMNode(newVNode, host, currElem); continue; } // If the old XOR new node has a custom renderer, // create and insert a new node. if (!oldVNode.renderer != !newVNode.renderer) { ArrayExt.insert(oldCopy, i, newVNode); createDOMNode(newVNode, host, currElem); continue; } // At this point, both nodes are known to be element nodes. // If the new elem is keyed, move an old keyed elem to the proper // location before proceeding with the diff. The search can start // at the current index, since the unmatched old keyed elems are // pushed forward in the old copy array. let newKey = newVNode.attrs.key; if (newKey && newKey in oldKeyed) { let pair = oldKeyed[newKey]; if (pair.vNode !== oldVNode) { ArrayExt.move(oldCopy, oldCopy.indexOf(pair.vNode, i + 1), i); host.insertBefore(pair.element, currElem); oldVNode = pair.vNode; currElem = pair.element; } } // If both elements are identical, there is nothing to do. if (oldVNode === newVNode) { currElem = currElem.nextSibling; continue; } // If the old elem is keyed and does not match the new elem key, // create a new node. This is necessary since the old keyed elem // may be matched at a later point in the diff. let oldKey = oldVNode.attrs.key; if (oldKey && oldKey !== newKey) { ArrayExt.insert(oldCopy, i, newVNode); createDOMNode(newVNode, host, currElem); continue; } // If the tags are different, create a new node. if (oldVNode.tag !== newVNode.tag) { ArrayExt.insert(oldCopy, i, newVNode); createDOMNode(newVNode, host, currElem); continue; } // At this point, the element can be updated in-place. // Update the element attributes. updateAttrs(currElem, oldVNode.attrs, newVNode.attrs); // Update the element content. if (newVNode.renderer) { newVNode.renderer.render(currElem, { attrs: newVNode.attrs, children: newVNode.children }); } else { updateContent(currElem, oldVNode.children, newVNode.children); } // Step to the next sibling element. currElem = currElem.nextSibling; } // Cleanup stale DOM removeContent(host, oldCopy, newCount, true); } Private.updateContent = updateContent; /** * Handle cleanup of stale vdom and its associated DOM. The host node is * traversed recursively (in depth-first order), and any explicit cleanup * required by a child node is carried out when it is visited (eg if a node * has a custom renderer, the renderer.unrender function will be called). * Once the subtree beneath each child of host has been completely visited, * that child will be removed via a call to host.removeChild. */ function removeContent(host, oldContent, newCount, _sentinel) { // Dispose of the old nodes pushed to the end of the host. for (let i = oldContent.length - 1; i >= newCount; --i) { const oldNode = oldContent[i]; const child = (_sentinel ? host.lastChild : host.childNodes[i]); // recursively clean up host children if (oldNode.type === 'text') ; else if (oldNode.renderer && oldNode.renderer.unrender) { oldNode.renderer.unrender(child, { attrs: oldNode.attrs, children: oldNode.children }); } else { removeContent(child, oldNode.children, 0, false); } if (_sentinel) { host.removeChild(child); } } } /** * A set of special-cased attribute names. */ const specialAttrs = { key: true, className: true, htmlFor: true, dataset: true, style: true }; /** * Add element attributes to a newly created HTML element. */ function addAttrs(element, attrs) { // Add the inline event listeners and node attributes. for (let name in attrs) { if (name in specialAttrs) { continue; } if (name.substr(0, 2) === 'on') { element[name] = attrs[name]; } else { element.setAttribute(name, attrs[name]); } } // Add the element `class` attribute. if (attrs.className !== undefined) { element.setAttribute('class', attrs.className); } // Add the element `for` attribute. if (attrs.htmlFor !== undefined) { element.setAttribute('for', attrs.htmlFor); } // Add the dataset values. if (attrs.dataset) { addDataset(element, attrs.dataset); } // Add the inline styles. if (attrs.style) { addStyle(element, attrs.style); } } /** * Update the element attributes of an HTML element. */ function updateAttrs(element, oldAttrs, newAttrs) { // Do nothing if the attrs are the same object. if (oldAttrs === newAttrs) { return; } // Setup the strongly typed loop variable. let name; // Remove attributes and listeners which no longer exist. for (name in oldAttrs) { if (name in specialAttrs || name in newAttrs) { continue; } if (name.substr(0, 2) === 'on') { element[name] = null; } else { element.removeAttribute(name); } } // Add and update new and existing attributes and listeners. for (name in newAttrs) { if (name in specialAttrs || oldAttrs[name] === newAttrs[name]) { continue; } if (name.substr(0, 2) === 'on') { element[name] = newAttrs[name]; } else { element.setAttribute(name, newAttrs[name]); } } // Update the element `class` attribute. if (oldAttrs.className !== newAttrs.className) { if (newAttrs.className !== undefined) { element.setAttribute('class', newAttrs.className); } else { element.removeAttribute('class'); } } // Add the element `for` attribute. if (oldAttrs.htmlFor !== newAttrs.htmlFor) { if (newAttrs.htmlFor !== undefined) { element.setAttribute('for', newAttrs.htmlFor); } else { element.removeAttribute('for'); } } // Update the dataset values. if (oldAttrs.dataset !== newAttrs.dataset) { updateDataset(element, oldAttrs.dataset || {}, newAttrs.dataset || {}); } // Update the inline styles. if (oldAttrs.style !== newAttrs.style) { updateStyle(element, oldAttrs.style || {}, newAttrs.style || {}); } } /** * Add dataset values to a newly created HTML element. */ function addDataset(element, dataset) { for (let name in dataset) { element.setAttribute(`data-${name}`, dataset[name]); } } /** * Update the dataset values of an HTML element. */ function updateDataset(element, oldDataset, newDataset) { for (let name in oldDataset) { if (!(name in newDataset)) { element.removeAttribute(`data-${name}`); } } for (let name in newDataset) { if (oldDataset[name] !== newDataset[name]) { element.setAttribute(`data-${name}`, newDataset[name]); } } } /** * Add inline style values to a newly created HTML element. */ function addStyle(element, style) { let elemStyle = element.style; let name; for (name in style) { elemStyle[name] = style[name]; } } /** * Update the inline style values of an HTML element. */ function updateStyle(element, oldStyle, newStyle) { let elemStyle = element.style; let name; for (name in oldStyle) { if (!(name in newStyle)) { elemStyle[name] = ''; } } for (name in newStyle) { if (oldStyle[name] !== newStyle[name]) { elemStyle[name] = newStyle[name]; } } } /** * Collect a mapping of keyed elements for the host content. */ function collectKeys(host, content) { let node = host.firstChild; let keyMap = Object.create(null); for (let vNode of content) { if (vNode.type === 'element' && vNode.attrs.key) { keyMap[vNode.attrs.key] = { vNode, element: node }; } node = node.nextSibling; } return keyMap; } })(Private || (Private = {})); export { VirtualDOM, VirtualElement, VirtualElementPass, VirtualText, h, hpass }; //# sourceMappingURL=index.es6.js.map