| // 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<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): 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` |
| <li role="treeitem" |
| ?selected=${input.jumpToNextSearchResult?.node === node} |
| @expand=${onExpand} |
| ?open=${containsSearchResult(node)}> |
| <devtools-highlight ranges=${highlights} current-range=${selected}> |
| ${htmlView(node)} |
| </devtools-highlight> |
| ${node.children().length ? html` |
| <ul role="group"> |
| ${ifExpanded(subtree(node))} |
| </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.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))} |
| </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; |
| } |
| } |