// 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; const {ifExpanded} = UI.TreeOutline; function* attributes(element: Element): Generator { 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}${ attributes(node) .map(attributeNode => `${'\xA0'}${attributeNode.name}${'="'}${attributeNode.value}${'"'}`) .toArray() .join('')}${ hasNonTextChildren(node) ? '' : node.textContent ? `${'>'}${node.textContent}${''}`; } return ''; case Node.TEXT_NODE: return node.nodeValue && !closeTag ? `${node.nodeValue}` : ''; case Node.CDATA_SECTION_NODE: return node.nodeValue && !closeTag ? `${''}` : ''; case Node.PROCESSING_INSTRUCTION_NODE: return node.nodeValue && !closeTag ? `${''}` : ''; case Node.COMMENT_NODE: return !closeTag ? `${''}` : ''; } 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`${'<' + tag}${ attributes(node).map(attributeNode => html`${'\xA0'} ${attributeNode.name} ${'="'} ${attributeNode.value} ${'"'}`)} ${ hasNonTextChildren(node) ? html`${'>'} ${'…'} ${'` : node.textContent ? html`${'>'} ${node.textContent} ${'` : html`${' /'}`} ${'>'}`; } return Lit.nothing; case Node.TEXT_NODE: return node.nodeValue ? html`${node.nodeValue}` : Lit.nothing; case Node.CDATA_SECTION_NODE: return node.nodeValue ? html`${' ${node.nodeValue} ${']]>'}` : Lit.nothing; case Node.PROCESSING_INSTRUCTION_NODE: return node.nodeValue ? html`${ ''}` : Lit.nothing; case Node.COMMENT_NODE: return html`${''}`; } return Lit.nothing; } interface ViewInput { onExpand(node: XMLTreeViewNode, expanded: boolean): void; xml: XMLTreeViewNode; search: UI.TreeOutline.TreeSearch|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): Lit.LitTemplate { const onExpand = (event: UI.TreeOutline.TreeViewElement.ExpandEvent): void => input.onExpand(node, event.detail.expanded); const {highlights, selected} = highlight(node, /* closeTag=*/ false); const containsSearchResult = (node: XMLTreeViewNode): boolean => { if (node === input.jumpToNextSearchResult?.node) { return true; } for (const child of node.children()) { if (containsSearchResult(child)) { return true; } } return false; }; // clang-format off return html`
  • ${htmlView(node)} ${node.children().length ? html`
      ${ifExpanded(subtree(node))}
    ` : Lit.nothing}
  • `; // 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.node instanceof Element ? html`
  • ${''}
  • ` : Lit.nothing}`; // clang-format on } // clang-format off render( html` ${input.xml.children().map(node => layOutNode(node))} `} >`, // clang-format on target); }; function* children(xmlNode: Node|ParentNode|undefined): Generator { 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 { 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 { match: RegExpExecArray; } export class XMLView extends UI.Widget.Widget implements UI.SearchableView.Searchable { private searchableView: UI.SearchableView.SearchableView|null = null; #search: UI.TreeOutline.TreeSearch|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; } }