chrome-devtools-frontend
Version:
Chrome DevTools UI
361 lines (325 loc) • 13.2 kB
text/typescript
// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../../../components/highlighting/highlighting.js';
import * as i18n from '../../../../core/i18n/i18n.js';
import * as TextUtils from '../../../../models/text_utils/text_utils.js';
import * as Lit from '../../../lit/lit.js';
import * as VisualLogging from '../../../visual_logging/visual_logging.js';
import * as UI from '../../legacy.js';
import xmlTreeStyles from './xmlTree.css.js';
import xmlViewStyles from './xmlView.css.js';
const UIStrings = {
/**
* @description Text to find an item
*/
find: 'Find',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/source_frame/XMLView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const {render, html} = Lit;
function* attributes(element: Element): Generator<Attr> {
for (let i = 0; i < element.attributes.length; ++i) {
const attributeNode = element.attributes.item(i);
if (attributeNode) {
yield attributeNode;
}
}
}
function hasNonTextChildren(node: Node): boolean {
return Boolean(node.childNodes.values().find(node => node.nodeType !== Node.TEXT_NODE));
}
function textView(treeNode: XMLTreeViewNode, closeTag: boolean): string {
const {node} = treeNode;
switch (node.nodeType) {
case Node.ELEMENT_NODE:
if (node instanceof Element) {
const tag = node.tagName;
return closeTag ?
hasNonTextChildren(node) || node.textContent ? '</' + tag + '>' : '' :
`${'<' + tag}${
attributes(node)
.map(attributeNode => `${'\xA0'}${attributeNode.name}${'="'}${attributeNode.value}${'"'}`)
.toArray()
.join('')}${
hasNonTextChildren(node) ? '' :
node.textContent ? `${'>'}${node.textContent}${'</' + tag}` :
`${' /'}`}${'>'}`;
}
return '';
case Node.TEXT_NODE:
return node.nodeValue && !closeTag ? `${node.nodeValue}` : '';
case Node.CDATA_SECTION_NODE:
return node.nodeValue && !closeTag ? `${'<![CDATA['}${node.nodeValue}${']]>'}` : '';
case Node.PROCESSING_INSTRUCTION_NODE:
return node.nodeValue && !closeTag ? `${'<?' + node.nodeName + ' ' + node.nodeValue + '?>'}` : '';
case Node.COMMENT_NODE:
return !closeTag ? `${'<!--' + node.nodeValue + '-->'}` : '';
}
return '';
}
function htmlView(treeNode: XMLTreeViewNode): Lit.LitTemplate {
const {node} = treeNode;
switch (node.nodeType) {
case Node.ELEMENT_NODE:
if (node instanceof Element) {
const tag = node.tagName;
return html`<span part='shadow-xml-view-tag'>${'<' + tag}</span>${
attributes(node).map(attributeNode => html`<span part='shadow-xml-view-tag'>${'\xA0'}</span>
<span part='shadow-xml-view-attribute-name'>${attributeNode.name}</span>
<span part='shadow-xml-view-tag'>${'="'}</span>
<span part='shadow-xml-view-attribute-value'>${attributeNode.value}</span>
<span part='shadow-xml-view-tag'>${'"'}</span>`)}
<span ?hidden=${treeNode.expanded}>${
hasNonTextChildren(node) ? html`<span part='shadow-xml-view-tag'>${'>'}</span>
<span part='shadow-xml-view-comment'>${'…'}</span>
<span part='shadow-xml-view-tag'>${'</' + tag}</span>` :
node.textContent ? html`<span part='shadow-xml-view-tag'>${'>'}</span>
<span part='shadow-xml-view-text'>${node.textContent}</span>
<span part='shadow-xml-view-tag'>${'</' + tag}</span>` :
html`<span part='shadow-xml-view-tag'>${' /'}</span>`}</span>
<span part='shadow-xml-view-tag'>${'>'}</span>`;
}
return Lit.nothing;
case Node.TEXT_NODE:
return node.nodeValue ? html`<span part='shadow-xml-view-text'>${node.nodeValue}</span>` : Lit.nothing;
case Node.CDATA_SECTION_NODE:
return node.nodeValue ? html`<span part='shadow-xml-view-cdata'>${'<![CDATA['}</span>
<span part='shadow-xml-view-text'>${node.nodeValue}</span>
<span part='shadow-xml-view-cdata'>${']]>'}</span>` :
Lit.nothing;
case Node.PROCESSING_INSTRUCTION_NODE:
return node.nodeValue ? html`<span part='shadow-xml-view-processing-instruction'>${
'<?' + node.nodeName + ' ' + node.nodeValue + '?>'}</span>` :
Lit.nothing;
case Node.COMMENT_NODE:
return html`<span part='shadow-xml-view-comment'>${'<!--' + node.nodeValue + '-->'}</span>`;
}
return Lit.nothing;
}
interface ViewInput {
onExpand(node: XMLTreeViewNode, expanded: boolean): void;
xml: XMLTreeViewNode;
search: UI.TreeOutline.TreeSearch<XMLTreeViewNode, SearchResult>|undefined;
jumpToNextSearchResult: SearchResult|undefined;
}
export type View = (input: ViewInput, output: object, target: HTMLElement) => void;
export const DEFAULT_VIEW: View = (input, output, target) => {
function highlight(node: XMLTreeViewNode, closeTag: boolean): {highlights: string, selected: string} {
let highlights = '';
let selected = '';
if (!input.search) {
return {highlights, selected};
}
const entries = input.search.getResults(node);
for (const entry of entries ?? []) {
if (entry.isPostOrderMatch === closeTag) {
const range = new TextUtils.TextRange.SourceRange(entry.match.index, entry.match[0].length);
if (entry === input.jumpToNextSearchResult) {
selected = `${range.offset},${range.length}`;
} else {
highlights += `${range.offset},${range.length} `;
}
}
}
return {highlights, selected};
}
function layOutNode(node: XMLTreeViewNode, populateSubtrees = false): Lit.LitTemplate {
const onExpand = (event: UI.TreeOutline.TreeViewElement.ExpandEvent): void =>
input.onExpand(node, event.detail.expanded);
const {highlights, selected} = highlight(node, /* closeTag=*/ false);
// clang-format off
return html`
<li role="treeitem"
?selected=${input.jumpToNextSearchResult?.node === node}
@expand=${onExpand}>
<devtools-highlight ranges=${highlights} current-range=${selected}>
${htmlView(node)}
</devtools-highlight>
${node.children().length ? html`
<ul role="group" ?hidden=${!node.expanded && input.jumpToNextSearchResult?.node !== node}>
${populateSubtrees || input.search ? subtree(node) : Lit.nothing}
</ul>` : Lit.nothing}
</li>`;
// clang-format on
}
function subtree(treeNode: XMLTreeViewNode): Lit.LitTemplate {
const children = treeNode.children();
if (children.length === 0) {
return Lit.nothing;
}
const {highlights, selected} = highlight(treeNode, /* closeTag=*/ true);
// clang-format off
return html`
${children.map(child => layOutNode(child, treeNode.expanded))}
${treeNode.node instanceof Element ? html`
<li role="treeitem">
<devtools-highlight ranges=${highlights} current-range=${selected}>
<span part='shadow-xml-view-close-tag'>${'</' + treeNode.node.tagName + '>'}</span>
</devtools-highlight>
</li>` : Lit.nothing}`;
// clang-format on
}
// clang-format off
render(
html`
<style>${xmlViewStyles}</style>
<style>${xmlTreeStyles}</style>
<devtools-tree
class="shadow-xml-view source-code"
.template=${html`
<ul role="tree">
${input.xml.children().map(node => layOutNode(node, /* populateSubtrees=*/ true))}
</ul>`}
></devtools-tree>`,
// clang-format on
target);
};
function* children(xmlNode: Node|ParentNode|undefined): Generator<Node> {
if (!xmlNode || !hasNonTextChildren(xmlNode)) {
return;
}
let node: (ChildNode|null) = xmlNode?.firstChild;
while (node) {
const currentNode = node;
node = node.nextSibling;
const nodeType = currentNode.nodeType;
// ignore empty TEXT
if (nodeType === Node.TEXT_NODE && currentNode.nodeValue?.match(/\s+/)) {
continue;
}
// ignore ATTRIBUTE, ENTITY_REFERENCE, ENTITY, DOCUMENT, DOCUMENT_TYPE, DOCUMENT_FRAGMENT, NOTATION
if ((nodeType !== Node.ELEMENT_NODE) && (nodeType !== Node.TEXT_NODE) && (nodeType !== Node.CDATA_SECTION_NODE) &&
(nodeType !== Node.PROCESSING_INSTRUCTION_NODE) && (nodeType !== Node.COMMENT_NODE)) {
continue;
}
yield currentNode;
}
}
export class XMLTreeViewNode {
readonly node: Node|ParentNode;
expanded = false;
#children?: XMLTreeViewNode[];
constructor(node: Node|ParentNode) {
this.node = node;
}
children(): XMLTreeViewNode[] {
if (!this.#children) {
this.#children = children(this.node).map(node => new XMLTreeViewNode(node)).toArray();
}
return this.#children;
}
match(regex: RegExp, closeTag: boolean): RegExpStringIterator<RegExpExecArray> {
return textView(this, closeTag).matchAll(regex);
}
}
export class XMLTreeViewModel {
readonly xmlDocument: Document;
readonly root: XMLTreeViewNode;
constructor(parsedXML: Document) {
this.xmlDocument = parsedXML;
this.root = new XMLTreeViewNode(parsedXML);
this.root.expanded = true;
}
}
interface SearchResult extends UI.TreeOutline.TreeSearchResult<XMLTreeViewNode> {
match: RegExpExecArray;
}
export class XMLView extends UI.Widget.Widget implements UI.SearchableView.Searchable {
private searchableView: UI.SearchableView.SearchableView|null = null;
#search: UI.TreeOutline.TreeSearch<XMLTreeViewNode, SearchResult>|undefined;
#treeViewModel: XMLTreeViewModel|undefined;
readonly #view: View;
#nextJump: SearchResult|undefined;
constructor(target?: HTMLElement, view: View = DEFAULT_VIEW) {
super(target, {jslog: `${VisualLogging.pane('xml-view')}`, classes: ['shadow-xml-view', 'source-code']});
this.#view = view;
}
set parsedXML(parsedXML: Document) {
if (this.#treeViewModel?.xmlDocument !== parsedXML) {
this.#treeViewModel = new XMLTreeViewModel(parsedXML);
this.requestUpdate();
}
}
override performUpdate(): void {
if (this.#treeViewModel) {
const onExpand = (node: XMLTreeViewNode, expanded: boolean): void => {
node.expanded = expanded;
this.requestUpdate();
};
this.#view(
{xml: this.#treeViewModel.root, onExpand, search: this.#search, jumpToNextSearchResult: this.#nextJump}, {},
this.contentElement);
}
}
static createSearchableView(parsedXML: Document): UI.SearchableView.SearchableView {
const xmlView = new XMLView();
xmlView.parsedXML = parsedXML;
const searchableView = new UI.SearchableView.SearchableView(xmlView, null);
searchableView.setPlaceholder(i18nString(UIStrings.find));
xmlView.searchableView = searchableView;
xmlView.show(searchableView.element);
return searchableView;
}
static parseXML(text: string, mimeType: string): Document|null {
let parsedXML;
try {
switch (mimeType) {
case 'application/xhtml+xml':
case 'application/xml':
case 'image/svg+xml':
case 'text/html':
case 'text/xml':
parsedXML = (new DOMParser()).parseFromString(text, mimeType);
}
} catch {
return null;
}
if (!parsedXML || parsedXML.body) {
return null;
}
return parsedXML;
}
onSearchCanceled(): void {
this.#search = undefined;
this.searchableView?.updateSearchMatchesCount(0);
this.searchableView?.updateCurrentMatchIndex(0);
}
performSearch(searchConfig: UI.SearchableView.SearchConfig, shouldJump: boolean, jumpBackwards?: boolean): void {
if (!this.#treeViewModel || !this.searchableView) {
return;
}
const {regex} = searchConfig.toSearchRegex(true);
if (!this.#search) {
this.#search = new UI.TreeOutline.TreeSearch();
}
this.#search.search(
this.#treeViewModel.root, jumpBackwards ?? false,
(node, closeTag) =>
node.match(regex, closeTag)
.map((match, matchIndexInNode) => ({node, matchIndexInNode, isPostOrderMatch: closeTag, match}))
.toArray());
this.#nextJump = shouldJump ? this.#search.currentMatch() : undefined;
this.#search.updateSearchableView(this.searchableView);
this.requestUpdate();
}
jumpToNextSearchResult(): void {
this.#nextJump = this.#search?.next();
this.searchableView && this.#search?.updateSearchableView(this.searchableView);
this.requestUpdate();
}
jumpToPreviousSearchResult(): void {
this.#nextJump = this.#search?.prev();
this.searchableView && this.#search?.updateSearchableView(this.searchableView);
this.requestUpdate();
}
supportsCaseSensitiveSearch(): boolean {
return true;
}
supportsWholeWordSearch(): boolean {
return true;
}
supportsRegexSearch(): boolean {
return true;
}
}