UNPKG

chrome-devtools-frontend

Version:
278 lines (248 loc) • 10.5 kB
// Copyright 2020 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as TreeOutline from '../../ui/components/tree_outline/tree_outline.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as Lit from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import * as AccessibilityTreeUtils from './AccessibilityTreeUtils.js'; import accessibilityTreeViewStyles from './accessibilityTreeView.css.js'; import {ElementsPanel} from './ElementsPanel.js'; const {html, render, Directives: {ref}} = Lit; const UIStrings = { /** * @description Text for copying, copy should be used as a verb */ copy: 'Copy', } as const; const str_ = i18n.i18n.registerUIStrings('panels/elements/AccessibilityTreeView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export interface ViewInput { nodes: AccessibilityTreeUtils.AXTreeNode[]; onNodeSelected: (node: SDK.AccessibilityModel.AccessibilityNode) => void; onNodeHighlight: (node: SDK.AccessibilityModel.AccessibilityNode) => void; onNodeClearHighlight: () => void; onCopy: (node: SDK.AccessibilityModel.AccessibilityNode) => void; } export interface ViewOutput { expandRoots?(): Promise<void>; revealNode?(ancestors: string[], nodeId: string): Promise<void>; } export type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void; export const DEFAULT_VIEW: View = (input, output, target) => { const treeData: TreeOutline.TreeOutline.TreeOutlineData<AccessibilityTreeUtils.AXTreeNodeData> = { defaultRenderer: AccessibilityTreeUtils.accessibilityNodeRenderer, tree: input.nodes, filter: node => { return node.ignored() || (node.role()?.value === 'generic' && !node.name()?.value) ? TreeOutline.TreeOutline.FilterOption.FLATTEN : TreeOutline.TreeOutline.FilterOption.SHOW; }, }; const onTreeOutlineRef = (el?: Element): void => { const treeOutline = el as TreeOutline.TreeOutline.TreeOutline<AccessibilityTreeUtils.AXTreeNodeData>| undefined; if (treeOutline) { output.expandRoots = () => treeOutline.expandRecursively(1); output.revealNode = (ancestors: string[], nodeId: string) => treeOutline.expandNodeIds(ancestors).then(() => treeOutline.focusNodeId(nodeId)); } else { output.expandRoots = undefined; output.revealNode = undefined; } }; let selectedAXNode: SDK.AccessibilityModel.AccessibilityNode|null = null; const onItemSelected = (event: TreeOutline.TreeOutline.ItemSelectedEvent<AccessibilityTreeUtils.AXTreeNodeData>): void => { selectedAXNode = event.data.node.treeNodeData; input.onNodeSelected(selectedAXNode); }; const onItemMouseOver = (event: TreeOutline.TreeOutline.ItemMouseOverEvent<AccessibilityTreeUtils.AXTreeNodeData>): void => { input.onNodeHighlight(event.data.node.treeNodeData); }; const onItemMouseOut = (): void => { input.onNodeClearHighlight(); }; const onItemContextMenu = (event: TreeOutline.TreeOutline.ItemContextMenuEvent<AccessibilityTreeUtils.AXTreeNodeData>): void => { event.data.originalEvent.preventDefault(); event.data.originalEvent.stopPropagation(); const contextMenu = new UI.ContextMenu.ContextMenu(event.data.originalEvent); const axNode = event.data.node.treeNodeData; contextMenu.clipboardSection().appendItem(i18nString(UIStrings.copy), () => input.onCopy(axNode), {jslogContext: 'copy'}); void contextMenu.show(); }; const onCopy = (event: ClipboardEvent): void => { event.preventDefault(); event.stopPropagation(); if (selectedAXNode) { input.onCopy(selectedAXNode); } }; // clang-format off render(html` <div class="accessibility-tree-view-container" jslog=${VisualLogging.tree('full-accessibility')} @copy=${onCopy}> <devtools-tree-outline .data=${treeData} @itemselected=${onItemSelected} @itemmouseover=${onItemMouseOver} @itemmouseout=${onItemMouseOut} @itemcontextmenu=${onItemContextMenu} ${ref(onTreeOutlineRef)}></devtools-tree-outline> </div> `, target); // clang-format on }; export class AccessibilityTreeView extends UI.Widget.VBox implements SDK.TargetManager.SDKModelObserver<SDK.AccessibilityModel.AccessibilityModel> { private inspectedDOMNode: SDK.DOMModel.DOMNode|null = null; private root: SDK.AccessibilityModel.AccessibilityNode|null = null; readonly #view: View; readonly #frameManager: SDK.FrameManager.FrameManager; readonly #treeOperations: ViewOutput = {}; constructor(view: View = DEFAULT_VIEW, frameManager: SDK.FrameManager.FrameManager = SDK.FrameManager.FrameManager.instance()) { super(); this.#view = view; this.registerRequiredCSS(accessibilityTreeViewStyles); this.#frameManager = frameManager; SDK.TargetManager.TargetManager.instance().observeModels(SDK.AccessibilityModel.AccessibilityModel, this, {scoped: true}); } #onNodeSelected = (axNode: SDK.AccessibilityModel.AccessibilityNode): void => { if (!axNode.isDOMNode()) { return; } const deferredNode = axNode.deferredDOMNode(); if (deferredNode) { deferredNode.resolve(domNode => { if (domNode) { this.inspectedDOMNode = domNode; void ElementsPanel.instance().revealAndSelectNode( domNode, {showPanel: true, focusNode: true, highlightInOverlay: true}); } }); } }; #onNodeHighlight = (axNode: SDK.AccessibilityModel.AccessibilityNode): void => { axNode.highlightDOMNode(); }; #onNodeClearHighlight = (): void => { SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(SDK.TargetManager.TargetManager.instance()); }; #onCopy = async(axNode: SDK.AccessibilityModel.AccessibilityNode): Promise<void> => { const text = await axNode.axNodeToText(); UI.UIUtils.copyTextToClipboard(text); }; override async wasShown(): Promise<void> { super.wasShown(); this.requestUpdate(); await this.refreshAccessibilityTree(); if (this.inspectedDOMNode) { await this.loadSubTreeIntoAccessibilityModel(this.inspectedDOMNode); } } override async performUpdate(): Promise<void> { let nodes: AccessibilityTreeUtils.AXTreeNode[] = []; if (!this.root) { const frameId = this.#frameManager.getOutermostFrame()?.id; if (frameId) { this.root = await SDK.AccessibilityModel.getRootNode(frameId, this.#frameManager); } } if (this.root) { nodes = await AccessibilityTreeUtils.sdkNodeToAXTreeNodes(this.root); } const input: ViewInput = { nodes, onNodeSelected: this.#onNodeSelected, onNodeHighlight: this.#onNodeHighlight, onNodeClearHighlight: this.#onNodeClearHighlight, onCopy: this.#onCopy, }; this.#view(input, this.#treeOperations, this.contentElement); } async refreshAccessibilityTree(): Promise<void> { if (!this.root) { const frameId = this.#frameManager.getOutermostFrame()?.id; if (!frameId) { throw new Error('No top frame'); } this.root = await SDK.AccessibilityModel.getRootNode(frameId, this.#frameManager); if (!this.root) { throw new Error('No root'); } } this.requestUpdate(); await this.updateComplete; await this.#treeOperations.expandRoots?.(); } // Given a selected DOM node, asks the model to load the missing subtree from the root to the // selected node and then re-renders the tree. async loadSubTreeIntoAccessibilityModel(selectedNode: SDK.DOMModel.DOMNode): Promise<void> { const ancestors = await SDK.AccessibilityModel.getNodeAndAncestorsFromDOMNode(selectedNode); const inspectedAXNode = ancestors.find(node => node.backendDOMNodeId() === selectedNode.backendNodeId()); if (!inspectedAXNode) { return; } this.requestUpdate(); await this.updateComplete; await this.#treeOperations.revealNode?.( ancestors.map(node => node.getFrameId() + '#' + node.id()), inspectedAXNode.getNodeId(), ); } // A node was revealed through the elements picker. async revealAndSelectNode(inspectedNode: SDK.DOMModel.DOMNode): Promise<void> { if (inspectedNode === this.inspectedDOMNode) { return; } this.inspectedDOMNode = inspectedNode; // We only want to load nodes into the model when the AccessibilityTree is visible. if (this.isShowing()) { await this.loadSubTreeIntoAccessibilityModel(inspectedNode); } } // Selected node in the DOM tree has changed. async selectedNodeChanged(inspectedNode: SDK.DOMModel.DOMNode): Promise<void> { if (this.isShowing() || (inspectedNode === this.inspectedDOMNode)) { return; } if (inspectedNode.ownerDocument && (inspectedNode.nodeName() === 'HTML' || inspectedNode.nodeName() === 'BODY')) { this.inspectedDOMNode = inspectedNode.ownerDocument; } else { this.inspectedDOMNode = inspectedNode; } } treeUpdated({data}: Common.EventTarget .EventTargetEvent<SDK.AccessibilityModel.EventTypes[SDK.AccessibilityModel.Events.TREE_UPDATED]>): void { if (data.root) { this.root = data.root; } if (!this.isShowing()) { return; } if (!data.root) { this.root = null; this.requestUpdate(); return; } const outermostFrameId = this.#frameManager.getOutermostFrame()?.id; if (data.root?.getFrameId() !== outermostFrameId) { this.requestUpdate(); return; } this.root = data.root; void this.refreshAccessibilityTree(); } modelAdded(model: SDK.AccessibilityModel.AccessibilityModel): void { model.addEventListener(SDK.AccessibilityModel.Events.TREE_UPDATED, this.treeUpdated, this); } modelRemoved(model: SDK.AccessibilityModel.AccessibilityModel): void { model.removeEventListener(SDK.AccessibilityModel.Events.TREE_UPDATED, this.treeUpdated, this); } }