@lumino/virtualdom
Version:
Lumino Virtual DOM
679 lines (676 loc) • 24.5 kB
JavaScript
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