blob: c8729b845eb207f9ec4774052725b3c0c8e0f356 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
export default class Fragment {
/**
* @param {!Element} element
*/
constructor(element) {
this._element = element;
/** @type {!Map<string, !Element>} */
this._elementsById = new Map();
}
/**
* @return {!Element}
*/
element() {
return this._element;
}
/**
* @param {string} elementId
* @return {!Element}
*/
$(elementId) {
return this._elementsById.get(elementId);
}
/**
* @param {!Array<string>} strings
* @param {...*} values
* @return {!Fragment}
*/
static build(strings, ...values) {
return Fragment._render(Fragment._template(strings), values);
}
/**
* @param {!Array<string>} strings
* @param {...*} values
* @return {!Fragment}
*/
static cached(strings, ...values) {
let template = _templateCache.get(strings);
if (!template) {
template = Fragment._template(strings);
_templateCache.set(strings, template);
}
return Fragment._render(template, values);
}
/**
* @param {!Array<string>} strings
* @return {!Fragment._Template}
* @suppressGlobalPropertiesCheck
*/
static _template(strings) {
let html = '';
let insideText = true;
for (let i = 0; i < strings.length - 1; i++) {
html += strings[i];
const close = strings[i].lastIndexOf('>');
const open = strings[i].indexOf('<', close + 1);
if (close !== -1 && open === -1) {
insideText = true;
} else if (open !== -1) {
insideText = false;
}
html += insideText ? Fragment._textMarker : Fragment._attributeMarker(i);
}
html += strings[strings.length - 1];
const template = window.document.createElement('template');
template.innerHTML = html;
const walker = template.ownerDocument.createTreeWalker(
template.content, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null, false);
let valueIndex = 0;
const emptyTextNodes = [];
const binds = [];
const nodesToMark = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttributes()) {
if (node.hasAttribute('$')) {
nodesToMark.push(node);
binds.push({elementId: node.getAttribute('$')});
node.removeAttribute('$');
}
const attributesToRemove = [];
for (let i = 0; i < node.attributes.length; i++) {
const name = node.attributes[i].name;
if (!_attributeMarkerRegex.test(name) && !_attributeMarkerRegex.test(node.attributes[i].value)) {
continue;
}
attributesToRemove.push(name);
nodesToMark.push(node);
const bind = {attr: {index: valueIndex}};
bind.attr.names = name.split(_attributeMarkerRegex);
valueIndex += bind.attr.names.length - 1;
bind.attr.values = node.attributes[i].value.split(_attributeMarkerRegex);
valueIndex += bind.attr.values.length - 1;
binds.push(bind);
}
for (let i = 0; i < attributesToRemove.length; i++) {
node.removeAttribute(attributesToRemove[i]);
}
}
if (node.nodeType === Node.TEXT_NODE && node.data.indexOf(Fragment._textMarker) !== -1) {
const texts = node.data.split(_textMarkerRegex);
node.data = texts[texts.length - 1];
for (let i = 0; i < texts.length - 1; i++) {
if (texts[i]) {
node.parentNode.insertBefore(createTextNode(texts[i]), node);
}
const nodeToReplace = createElement('span');
nodesToMark.push(nodeToReplace);
binds.push({replaceNodeIndex: valueIndex++});
node.parentNode.insertBefore(nodeToReplace, node);
}
}
if (node.nodeType === Node.TEXT_NODE &&
(!node.previousSibling || node.previousSibling.nodeType === Node.ELEMENT_NODE) &&
(!node.nextSibling || node.nextSibling.nodeType === Node.ELEMENT_NODE) && /^\s*$/.test(node.data)) {
emptyTextNodes.push(node);
}
}
for (let i = 0; i < nodesToMark.length; i++) {
nodesToMark[i].classList.add(_class(i));
}
for (const emptyTextNode of emptyTextNodes) {
emptyTextNode.remove();
}
return {template: template, binds: binds};
}
/**
* @param {!Fragment._Template} template
* @param {!Array<*>} values
* @return {!Fragment}
*/
static _render(template, values) {
const content = template.template.ownerDocument.importNode(template.template.content, true);
const resultElement =
/** @type {!Element} */ (content.firstChild === content.lastChild ? content.firstChild : content);
const result = new Fragment(resultElement);
const boundElements = [];
for (let i = 0; i < template.binds.length; i++) {
const className = _class(i);
const element = /** @type {!Element} */ (content.querySelector('.' + className));
element.classList.remove(className);
boundElements.push(element);
}
for (let bindIndex = 0; bindIndex < template.binds.length; bindIndex++) {
const bind = template.binds[bindIndex];
const element = boundElements[bindIndex];
if ('elementId' in bind) {
result._elementsById.set(/** @type {string} */ (bind.elementId), element);
} else if ('replaceNodeIndex' in bind) {
const value = values[/** @type {number} */ (bind.replaceNodeIndex)];
element.parentNode.replaceChild(this._nodeForValue(value), element);
} else if ('attr' in bind) {
if (bind.attr.names.length === 2 && bind.attr.values.length === 1 &&
typeof values[bind.attr.index] === 'function') {
values[bind.attr.index].call(null, element);
} else {
let name = bind.attr.names[0];
for (let i = 1; i < bind.attr.names.length; i++) {
name += values[bind.attr.index + i - 1];
name += bind.attr.names[i];
}
if (name) {
let value = bind.attr.values[0];
for (let i = 1; i < bind.attr.values.length; i++) {
value += values[bind.attr.index + bind.attr.names.length - 1 + i - 1];
value += bind.attr.values[i];
}
element.setAttribute(name, value);
}
}
} else {
throw new Error('Unexpected bind');
}
}
return result;
}
/**
* @param {*} value
* @return {!Node}
*/
static _nodeForValue(value) {
if (value instanceof Node) {
return value;
}
if (value instanceof Fragment) {
return value._element;
}
if (Array.isArray(value)) {
const node = createDocumentFragment();
for (const v of value) {
node.appendChild(this._nodeForValue(v));
}
return node;
}
return createTextNode('' + value);
}
}
export const _textMarker = '{{template-text}}';
const _textMarkerRegex = /{{template-text}}/;
export const _attributeMarker = index => 'template-attribute' + index;
const _attributeMarkerRegex = /template-attribute\d+/;
const _class = index => 'template-class-' + index;
const _templateCache = new Map();
/**
* @param {!Array<string>} strings
* @param {...*} vararg
* @return {!Element}
*/
export const html = (strings, ...vararg) => {
return Fragment.cached(strings, ...vararg).element();
};
/* Legacy exported object*/
self.UI = self.UI || {};
/* Legacy exported object*/
UI = UI || {};
/** @constructor */
UI.Fragment = Fragment;
UI.Fragment._textMarker = _textMarker;
UI.Fragment._attributeMarker = _attributeMarker;
UI.html = html;
/**
* @typedef {!{
* template: !Element,
* binds: !Array<!Fragment._Bind>
* }}
*/
UI.Fragment._Template;
/**
* @typedef {!{
* elementId: (string|undefined),
*
* attr: (!{
* index: number,
* names: !Array<string>,
* values: !Array<string>
* }|undefined),
*
* replaceNodeIndex: (number|undefined)
* }}
*/
UI.Fragment._Bind;