Commit 48b444dd authored by Dmitry Gozman's avatar Dmitry Gozman Committed by Commit Bot

[DevTools] Introduce UI.Fragment

Fragment is a piece of DOM generated from a template string,
to be used instead of manual dom construction.

- Allows functions ("binds") to be executed during instantiation:
    <div ${(fragment, node) => node._owner = fragment} />.

- Exposes selected elements via $:
    <div $=myName />.

- Toggling states which affect arbitrary attributes:
    <div s-selected-attr=value /> changes the value of "attr" to "value".

- Inserting elements into markup:
    <div>Some ${myDiv} text</div>.

- Inserting text into markup:
    <div>Some ${myText} text</div>.

- Composed attribute names:
    <div ${foo ? 'foo' : 'bar'}-name=value />.

- Transforms <x-shadow> to shadow root:
    <div><x-shadow>dark dom here</x-shadow></div>.

- Caching preprocessed template if needed.

Bug: none
Change-Id: Ib1eb7c10b0f3f4aaebf98c022697111f38d55f9f
Reviewed-on: https://chromium-review.googlesource.com/809952Reviewed-by: default avatarAndrey Lushnikov <lushnikov@chromium.org>
Commit-Queue: Dmitry Gozman <dgozman@chromium.org>
Cr-Commit-Position: refs/heads/master@{#523680}
parent 53edf03e
Tests how fragment works.
f1.outerHTML:
<div-a attr="val" class=""><div-c class="my-class-1 my-class-2" foo="bar">Some text here And more text</div-c>
<div></div></div-a>
() => diva === f1.element()
true
() => diva.tagName === 'DIV-A'
true
() => divb.tagName === 'DIV-B'
true
() => divc.tagName === 'DIV-C'
true
() => shadow.nodeType === Node.DOCUMENT_FRAGMENT_NODE
true
() => shadow.parentElementOrShadowHost() === diva
true
() => divc.parentNode === diva
true
() => divb.parentElementOrShadowHost() === diva
true
() => diva.lastChild === inner
true
() => divb.getAttribute('foo1') === 'bar1'
true
() => divb.getAttribute('foo2') === 'bar2'
true
() => divb.divb === true
true
() => divc.textContent === 'Some text here And more text'
true
() => divc.classList.contains('my-class-1')
true
() => divc.classList.contains('my-class-2')
true
() => divc.getAttribute('foo') === 'bar'
true
() => diva.getAttribute('attr') === 'val'
true
() => divb.getAttribute('attr') === null
true
() => diva.getAttribute('attr') === 'val-state1'
true
() => divb.getAttribute('attr') === 'val-state1'
true
() => diva.getAttribute('attr') === 'val-state1'
true
() => divb.getAttribute('attr') === 'val-state1'
true
() => diva.getAttribute('attr') === 'val'
true
() => divb.getAttribute('attr') === null
true
() => diva.getAttribute('attr') === 'val-state2'
true
() => divb.getAttribute('attr') === null
true
f2.outerHTML:
<div><div-a attr="val-state2" class=""><div-c class="my-class-1 my-class-2" foo="bar">Some text here And more text</div-c>
<div></div></div-a></div>
() => f2.element().firstChild === f1.element()
true
f3.outerHTML:
<div><div><div-a attr="val-state2" class=""><div-c class="my-class-1 my-class-2" foo="bar">Some text here And more text</div-c>
<div></div></div-a></div></div>
() => f3.element().firstChild === f2.element()
true
// 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.
(async function() {
function check(f) {
TestRunner.addResult(f.toString() + '\n ' + f());
}
TestRunner.addResult(`Tests how fragment works.\n`);
var inner = document.createElement('div');
var f1 = UI.Fragment.build`
<div-a $=name-a attr=val s-state1-attr=val-state1 s-state2-attr=val-state2>
<x-shadow $=name-shadow>
<div-b $=name-b foo1=bar1 foo${'2'}=${'b'}ar${'2'} ${''} ${element => element.divb = true} s-state1-attr=val-state1>
</div-b>
</x-shadow>
<div-c $=name-c class='${'my-class-1'} my-class-2' ${'foo'}=bar>${'Some text here'} ${'And more text'}</div-c>
${inner}
</div-a>
`;
TestRunner.addResult('f1.outerHTML:');
TestRunner.addResult(f1.element().outerHTML);
var diva = f1.$('name-a');
var divb = f1.$('name-b');
var divc = f1.$('name-c');
var shadow = f1.$('name-shadow');
check(() => diva === f1.element());
check(() => diva.tagName === 'DIV-A');
check(() => divb.tagName === 'DIV-B');
check(() => divc.tagName === 'DIV-C');
check(() => shadow.nodeType === Node.DOCUMENT_FRAGMENT_NODE);
check(() => shadow.parentElementOrShadowHost() === diva);
check(() => divc.parentNode === diva);
check(() => divb.parentElementOrShadowHost() === diva);
check(() => diva.lastChild === inner);
check(() => divb.getAttribute('foo1') === 'bar1');
check(() => divb.getAttribute('foo2') === 'bar2');
check(() => divb.divb === true);
check(() => divc.textContent === 'Some text here And more text');
check(() => divc.classList.contains('my-class-1'));
check(() => divc.classList.contains('my-class-2'));
check(() => divc.getAttribute('foo') === 'bar');
check(() => diva.getAttribute('attr') === 'val');
check(() => divb.getAttribute('attr') === null);
f1.setState('state1', true);
check(() => diva.getAttribute('attr') === 'val-state1');
check(() => divb.getAttribute('attr') === 'val-state1');
f1.setState('state1', true);
check(() => diva.getAttribute('attr') === 'val-state1');
check(() => divb.getAttribute('attr') === 'val-state1');
f1.setState('state1', false);
check(() => diva.getAttribute('attr') === 'val');
check(() => divb.getAttribute('attr') === null);
f1.setState('state2', true);
check(() => diva.getAttribute('attr') === 'val-state2');
check(() => divb.getAttribute('attr') === null);
TestRunner.addResult('');
function cached(child) {
return UI.Fragment.cached`
<div>${child}</div>
`;
}
var f2 = cached(f1);
TestRunner.addResult('f2.outerHTML:');
TestRunner.addResult(f2.element().outerHTML);
check(() => f2.element().firstChild === f1.element());
TestRunner.addResult('');
var f3 = cached(f2);
TestRunner.addResult('f3.outerHTML:');
TestRunner.addResult(f3.element().outerHTML);
check(() => f3.element().firstChild === f2.element());
TestRunner.addResult('');
TestRunner.completeTest();
})();
...@@ -728,6 +728,7 @@ all_devtools_files = [ ...@@ -728,6 +728,7 @@ all_devtools_files = [
"front_end/ui/FilterBar.js", "front_end/ui/FilterBar.js",
"front_end/ui/FilterSuggestionBuilder.js", "front_end/ui/FilterSuggestionBuilder.js",
"front_end/ui/ForwardedInputEventHandler.js", "front_end/ui/ForwardedInputEventHandler.js",
"front_end/ui/Fragment.js",
"front_end/ui/Geometry.js", "front_end/ui/Geometry.js",
"front_end/ui/glassPane.css", "front_end/ui/glassPane.css",
"front_end/ui/GlassPane.js", "front_end/ui/GlassPane.js",
......
...@@ -232,6 +232,8 @@ Node.prototype.enclosingNodeOrSelfWithClassList = function(classNames, stayWithi ...@@ -232,6 +232,8 @@ Node.prototype.enclosingNodeOrSelfWithClassList = function(classNames, stayWithi
* @return {?Element} * @return {?Element}
*/ */
Node.prototype.parentElementOrShadowHost = function() { Node.prototype.parentElementOrShadowHost = function() {
if (this.nodeType === Node.DOCUMENT_FRAGMENT_NODE && this.host)
return /** @type {!Element} */ (this.host);
var node = this.parentNode; var node = this.parentNode;
if (!node) if (!node)
return null; return null;
......
// 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.
UI.Fragment = class {
/**
* @param {!Element} element
*/
constructor(element) {
this._element = element;
/** @type {!Map<string, !Array<!UI.Fragment._State>>} */
this._states = new Map();
/** @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 {string} name
* @param {boolean} toggled
*/
setState(name, toggled) {
var list = this._states.get(name);
if (list === undefined) {
console.error('Unknown state ' + name);
return;
}
for (var state of list) {
if (state.toggled === toggled)
continue;
state.toggled = toggled;
var value = state.attributeValue;
state.attributeValue = state.element.getAttribute(state.attributeName);
if (value === null)
state.element.removeAttribute(state.attributeName);
else
state.element.setAttribute(state.attributeName, value);
}
}
/**
* @param {!Array<string>} strings
* @param {...*} vararg
* @return {!UI.Fragment}
*/
static build(strings, vararg) {
var values = Array.prototype.slice.call(arguments, 1);
return UI.Fragment._render(UI.Fragment._template(strings), values);
}
/**
* @param {!Array<string>} strings
* @param {...*} vararg
* @return {!UI.Fragment}
*/
static cached(strings, vararg) {
var values = Array.prototype.slice.call(arguments, 1);
var template = UI.Fragment._templateCache.get(strings);
if (!template) {
template = UI.Fragment._template(strings);
UI.Fragment._templateCache.set(strings, template);
}
return UI.Fragment._render(template, values);
}
/**
* @param {!Array<string>} strings
* @return {!UI.Fragment._Template}
* @suppressGlobalPropertiesCheck
*/
static _template(strings) {
var html = '';
var insideText = false;
for (var i = 0; i < strings.length - 1; i++) {
html += strings[i];
var close = strings[i].lastIndexOf('>');
if (close !== -1) {
if (strings[i].indexOf('<', close + 1) === -1)
insideText = true;
else
insideText = false;
}
html += insideText ? UI.Fragment._textMarker : UI.Fragment._attributeMarker(i);
}
html += strings[strings.length - 1];
var template = window.document.createElement('template');
template.innerHTML = html;
var walker = template.ownerDocument.createTreeWalker(
template.content, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null, false);
var valueIndex = 0;
var emptyTextNodes = [];
var binds = [];
var nodesToMark = [];
while (walker.nextNode()) {
var node = walker.currentNode;
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttributes()) {
if (node.hasAttribute('$')) {
nodesToMark.push(node);
binds.push({elementId: node.getAttribute('$')});
node.removeAttribute('$');
}
var attributesToRemove = [];
for (var i = 0; i < node.attributes.length; i++) {
var name = node.attributes[i].name;
if (name.startsWith('s-')) {
attributesToRemove.push(name);
name = name.substring(2);
var state = name.substring(0, name.indexOf('-'));
var attr = name.substring(state.length + 1);
nodesToMark.push(node);
binds.push({state: {name: state, attribute: attr, value: node.attributes[i].value}});
continue;
}
if (!UI.Fragment._attributeMarkerRegex.test(name) &&
!UI.Fragment._attributeMarkerRegex.test(node.attributes[i].value))
continue;
attributesToRemove.push(name);
nodesToMark.push(node);
var bind = {attr: {index: valueIndex}};
bind.attr.names = name.split(UI.Fragment._attributeMarkerRegex);
valueIndex += bind.attr.names.length - 1;
bind.attr.values = node.attributes[i].value.split(UI.Fragment._attributeMarkerRegex);
valueIndex += bind.attr.values.length - 1;
binds.push(bind);
}
for (var i = 0; i < attributesToRemove.length; i++)
node.removeAttribute(attributesToRemove[i]);
}
if (node.nodeType === Node.TEXT_NODE && node.data.indexOf(UI.Fragment._textMarker) !== -1) {
var texts = node.data.split(UI.Fragment._textMarkerRegex);
node.data = texts[texts.length - 1];
for (var i = 0; i < texts.length - 1; i++) {
if (texts[i])
node.parentNode.insertBefore(createTextNode(texts[i]), node);
var 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 (var i = 0; i < nodesToMark.length; i++)
nodesToMark[i].classList.add(UI.Fragment._class(i));
for (var emptyTextNode of emptyTextNodes)
emptyTextNode.remove();
return {template: template, binds: binds};
}
/**
* @param {!UI.Fragment._Template} template
* @param {!Array<*>} values
* @return {!UI.Fragment}
*/
static _render(template, values) {
var content = template.template.ownerDocument.importNode(template.template.content, true);
var resultElement =
/** @type {!Element} */ (content.firstChild === content.lastChild ? content.firstChild : content);
var result = new UI.Fragment(resultElement);
var idByElement = new Map();
var boundElements = [];
for (var i = 0; i < template.binds.length; i++) {
var className = UI.Fragment._class(i);
var element = /** @type {!Element} */ (content.querySelector('.' + className));
element.classList.remove(className);
boundElements.push(element);
}
for (var bindIndex = 0; bindIndex < template.binds.length; bindIndex++) {
var bind = template.binds[bindIndex];
var element = boundElements[bindIndex];
if ('elementId' in bind) {
result._elementsById.set(/** @type {string} */ (bind.elementId), element);
idByElement.set(element, bind.elementId);
} else if ('replaceNodeIndex' in bind) {
var value = values[/** @type {number} */ (bind.replaceNodeIndex)];
var node = null;
if (value instanceof Node)
node = value;
else if (value instanceof UI.Fragment)
node = value._element;
else
node = createTextNode('' + value);
element.parentNode.insertBefore(node, element);
element.remove();
} else if ('state' in bind) {
var list = result._states.get(bind.state.name) || [];
list.push(
{attributeName: bind.state.attribute, attributeValue: bind.state.value, element: element, toggled: false});
result._states.set(bind.state.name, list);
} 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 {
var name = bind.attr.names[0];
for (var i = 1; i < bind.attr.names.length; i++) {
name += values[bind.attr.index + i - 1];
name += bind.attr.names[i];
}
if (name) {
var value = bind.attr.values[0];
for (var 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 'Unexpected bind';
}
}
// We do this after binds so that querySelector works.
var shadows = result._element.querySelectorAll('x-shadow');
for (var shadow of shadows) {
if (!shadow.parentElement)
throw 'There must be a parent element here';
var shadowRoot = UI.createShadowRootWithCoreStyles(shadow.parentElement);
if (shadow.parentElement.tagName === 'X-WIDGET')
shadow.parentElement._shadowRoot = shadowRoot;
var children = [];
while (shadow.lastChild) {
children.push(shadow.lastChild);
shadow.lastChild.remove();
}
for (var i = children.length - 1; i >= 0; i--)
shadowRoot.appendChild(children[i]);
var id = idByElement.get(shadow);
if (id)
result._elementsById.set(id, /** @type {!Element} */ (/** @type {!Node} */ (shadowRoot)));
shadow.remove();
}
return result;
}
};
/**
* @typedef {!{
* template: !Element,
* binds: !Array<!UI.Fragment._Bind>
* }}
*/
UI.Fragment._Template;
/**
* @typedef {!{
* attributeName: string,
* attributeValue: string,
* element: !Element,
* toggled: boolean
* }}
*/
UI.Fragment._State;
/**
* @typedef {!{
* elementId: (string|undefined),
*
* state: (!{
* name: string,
* attribute: string,
* value: string
* }|undefined),
*
* attr: (!{
* index: number,
* names: !Array<string>,
* values: !Array<string>
* }|undefined),
*
* replaceNodeIndex: (number|undefined)
* }}
*/
UI.Fragment._Bind;
UI.Fragment._textMarker = '{{template-text}}';
UI.Fragment._textMarkerRegex = /{{template-text}}/;
UI.Fragment._attributeMarker = index => 'template-attribute' + index;
UI.Fragment._attributeMarkerRegex = /template-attribute\d+/;
UI.Fragment._class = index => 'template-class-' + index;
UI.Fragment._templateCache = new Map();
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
"FilterBar.js", "FilterBar.js",
"FilterSuggestionBuilder.js", "FilterSuggestionBuilder.js",
"ForwardedInputEventHandler.js", "ForwardedInputEventHandler.js",
"Fragment.js",
"HistoryInput.js", "HistoryInput.js",
"Icon.js", "Icon.js",
"Infobar.js", "Infobar.js",
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment