chrome-devtools-frontend
Version: 
Chrome DevTools UI
382 lines (330 loc) • 13.6 kB
text/typescript
// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import classesPaneWidgetStyles from './classesPaneWidget.css.js';
import {ElementsPanel} from './ElementsPanel.js';
const UIStrings = {
  /**
   * @description Prompt text for a text field in the Classes Pane Widget of the Elements panel.
   * Class refers to a CSS class.
   */
  addNewClass: 'Add new class',
  /**
   * @description Screen reader announcement string when adding a CSS class via the Classes Pane Widget.
   * @example {vbox flex-auto} PH1
   */
  classesSAdded: 'Classes {PH1} added',
  /**
   * @description Screen reader announcement string when adding a class via the Classes Pane Widget.
   * @example {title-container} PH1
   */
  classSAdded: 'Class {PH1} added',
  /**
   * @description Accessible title read by screen readers for the Classes Pane Widget of the Elements
   * panel. Element is a HTML DOM Element and classes refers to CSS classes.
   */
  elementClasses: 'Element Classes',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/elements/ClassesPaneWidget.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class ClassesPaneWidget extends UI.Widget.Widget {
  private input: HTMLElement;
  private readonly classesContainer: HTMLElement;
  private readonly prompt: ClassNamePrompt;
  private readonly mutatingNodes: Set<SDK.DOMModel.DOMNode>;
  private readonly pendingNodeClasses: Map<SDK.DOMModel.DOMNode, string>;
  private readonly updateNodeThrottler: Common.Throttler.Throttler;
  private previousTarget: SDK.DOMModel.DOMNode|null;
  constructor() {
    super({
      jslog: `${VisualLogging.pane('elements-classes')}`,
      useShadowDom: true,
    });
    this.registerRequiredCSS(classesPaneWidgetStyles);
    this.contentElement.className = 'styles-element-classes-pane';
    const container = this.contentElement.createChild('div', 'title-container');
    this.input = container.createChild('div', 'new-class-input monospace');
    this.setDefaultFocusedElement(this.input);
    this.classesContainer = this.contentElement.createChild('div', 'source-code');
    this.classesContainer.classList.add('styles-element-classes-container');
    this.prompt = new ClassNamePrompt(this.nodeClasses.bind(this));
    this.prompt.setAutocompletionTimeout(0);
    this.prompt.renderAsBlock();
    const proxyElement = (this.prompt.attach(this.input) as HTMLElement);
    this.prompt.setPlaceholder(i18nString(UIStrings.addNewClass));
    this.prompt.addEventListener(UI.TextPrompt.Events.TEXT_CHANGED, this.onTextChanged, this);
    proxyElement.addEventListener('keydown', this.onKeyDown.bind(this), false);
    SDK.TargetManager.TargetManager.instance().addModelListener(
        SDK.DOMModel.DOMModel, SDK.DOMModel.Events.DOMMutated, this.onDOMMutated, this, {scoped: true});
    this.mutatingNodes = new Set();
    this.pendingNodeClasses = new Map();
    this.updateNodeThrottler = new Common.Throttler.Throttler(0);
    this.previousTarget = null;
    UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, this.onSelectedNodeChanged, this);
  }
  private splitTextIntoClasses(text: string): string[] {
    return text.split(/[,\s]/).map(className => className.trim()).filter(className => className.length);
  }
  private onKeyDown(event: KeyboardEvent): void {
    if (!(event.key === 'Enter') && !Platform.KeyboardUtilities.isEscKey(event)) {
      return;
    }
    if (event.key === 'Enter') {
      event.consume();
      if (this.prompt.acceptAutoComplete()) {
        return;
      }
    }
    const eventTarget = (event.target as HTMLElement);
    let text = eventTarget.textContent;
    if (Platform.KeyboardUtilities.isEscKey(event)) {
      if (!Platform.StringUtilities.isWhitespace(text)) {
        event.consume(true);
      }
      text = '';
    }
    this.prompt.clearAutocomplete();
    eventTarget.textContent = '';
    const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
    if (!node) {
      return;
    }
    const classNames = this.splitTextIntoClasses(text);
    if (!classNames.length) {
      this.installNodeClasses(node);
      return;
    }
    for (const className of classNames) {
      this.toggleClass(node, className, true);
    }
    // annoucementString is used for screen reader to announce that the class(es) has been added successfully.
    const joinClassString = classNames.join(' ');
    const announcementString = classNames.length > 1 ? i18nString(UIStrings.classesSAdded, {PH1: joinClassString}) :
                                                       i18nString(UIStrings.classSAdded, {PH1: joinClassString});
    UI.ARIAUtils.LiveAnnouncer.alert(announcementString);
    this.installNodeClasses(node);
    this.update();
  }
  private onTextChanged(): void {
    const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
    if (!node) {
      return;
    }
    this.installNodeClasses(node);
  }
  private onDOMMutated(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
    const node = event.data;
    if (this.mutatingNodes.has(node)) {
      return;
    }
    cachedClassesMap.delete(node);
    this.update();
  }
  private onSelectedNodeChanged(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode|null>): void {
    if (this.previousTarget && this.prompt.text()) {
      this.input.textContent = '';
      this.installNodeClasses(this.previousTarget);
    }
    this.previousTarget = event.data;
    this.update();
  }
  override wasShown(): void {
    super.wasShown();
    this.update();
  }
  update(): void {
    if (!this.isShowing()) {
      return;
    }
    let node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
    if (node) {
      node = node.enclosingElementOrSelf();
    }
    this.classesContainer.removeChildren();
    // @ts-expect-error this.input is a div, not an input element. So this line makes no sense at all
    this.input.disabled = !node;
    if (!node) {
      return;
    }
    const classes = this.nodeClasses(node);
    const keys = [...classes.keys()];
    keys.sort(Platform.StringUtilities.caseInsensetiveComparator);
    for (const className of keys) {
      const checkbox =
          UI.UIUtils.CheckboxLabel.createWithStringLiteral(className, classes.get(className), 'element-class', true);
      checkbox.classList.add('monospace');
      checkbox.addEventListener('click', this.onClick.bind(this, className), false);
      this.classesContainer.appendChild(checkbox);
    }
  }
  private onClick(className: string, event: Event): void {
    const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
    if (!node) {
      return;
    }
    const enabled = (event.target as HTMLInputElement).checked;
    this.toggleClass(node, className, enabled);
    this.installNodeClasses(node);
  }
  private nodeClasses(node: SDK.DOMModel.DOMNode): Map<string, boolean> {
    let result = cachedClassesMap.get(node);
    if (!result) {
      const classAttribute = node.getAttribute('class') || '';
      const classes = classAttribute.split(/\s/);
      result = new Map();
      for (let i = 0; i < classes.length; ++i) {
        const className = classes[i].trim();
        if (!className.length) {
          continue;
        }
        result.set(className, true);
      }
      cachedClassesMap.set(node, result);
    }
    return result;
  }
  private toggleClass(node: SDK.DOMModel.DOMNode, className: string, enabled: boolean): void {
    const classes = this.nodeClasses(node);
    classes.set(className, enabled);
    ButtonProvider.instance().item().setChecked([...classes.values()].includes(true));
  }
  private installNodeClasses(node: SDK.DOMModel.DOMNode): void {
    const classes = this.nodeClasses(node);
    const activeClasses = new Set<string>();
    for (const className of classes.keys()) {
      if (classes.get(className)) {
        activeClasses.add(className);
      }
    }
    const additionalClasses = this.splitTextIntoClasses(this.prompt.textWithCurrentSuggestion());
    for (const className of additionalClasses) {
      activeClasses.add(className);
    }
    const newClasses = [...activeClasses.values()].sort();
    this.pendingNodeClasses.set(node, newClasses.join(' '));
    void this.updateNodeThrottler.schedule(this.flushPendingClasses.bind(this));
  }
  private async flushPendingClasses(): Promise<void> {
    const promises = [];
    for (const node of this.pendingNodeClasses.keys()) {
      this.mutatingNodes.add(node);
      const promise = node.setAttributeValuePromise('class', (this.pendingNodeClasses.get(node) as string))
                          .then(onClassValueUpdated.bind(this, node));
      promises.push(promise);
    }
    this.pendingNodeClasses.clear();
    await Promise.all(promises);
    function onClassValueUpdated(this: ClassesPaneWidget, node: SDK.DOMModel.DOMNode): void {
      this.mutatingNodes.delete(node);
    }
  }
}
const cachedClassesMap = new WeakMap<SDK.DOMModel.DOMNode, Map<string, boolean>>();
let buttonProviderInstance: ButtonProvider;
export class ButtonProvider implements UI.Toolbar.Provider {
  private readonly button: UI.Toolbar.ToolbarToggle;
  private view: ClassesPaneWidget;
  private constructor() {
    this.button = new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.elementClasses), 'class');
    this.button.element.style.setProperty('--dot-toggle-top', '12px');
    this.button.element.style.setProperty('--dot-toggle-left', '18px');
    this.button.element.setAttribute(
        'jslog', `${VisualLogging.toggleSubpane('elements-classes').track({click: true})}`);
    this.button.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.clicked, this);
    this.view = new ClassesPaneWidget();
  }
  static instance(opts: {
    forceNew: boolean|null,
  } = {forceNew: null}): ButtonProvider {
    const {forceNew} = opts;
    if (!buttonProviderInstance || forceNew) {
      buttonProviderInstance = new ButtonProvider();
    }
    return buttonProviderInstance;
  }
  private clicked(): void {
    ElementsPanel.instance().showToolbarPane(!this.view.isShowing() ? this.view : null, this.button);
  }
  item(): UI.Toolbar.ToolbarToggle {
    return this.button;
  }
}
export class ClassNamePrompt extends UI.TextPrompt.TextPrompt {
  private readonly nodeClasses: (arg0: SDK.DOMModel.DOMNode) => Map<string, boolean>;
  private selectedFrameId: string|null;
  private classNamesPromise: Promise<string[]>|null;
  constructor(nodeClasses: (arg0: SDK.DOMModel.DOMNode) => Map<string, boolean>) {
    super();
    this.nodeClasses = nodeClasses;
    this.initialize(this.buildClassNameCompletions.bind(this), ' ');
    this.disableDefaultSuggestionForEmptyInput();
    this.selectedFrameId = '';
    this.classNamesPromise = null;
  }
  private async getClassNames(selectedNode: SDK.DOMModel.DOMNode): Promise<string[]> {
    const promises = [];
    const completions = new Set<string>();
    this.selectedFrameId = selectedNode.frameId();
    const cssModel = selectedNode.domModel().cssModel();
    const allStyleSheets = cssModel.allStyleSheets();
    for (const stylesheet of allStyleSheets) {
      if (stylesheet.frameId !== this.selectedFrameId) {
        continue;
      }
      const cssPromise = cssModel.getClassNames(stylesheet.id).then(classes => {
        for (const className of classes) {
          completions.add(className);
        }
      });
      promises.push(cssPromise);
    }
    const ownerDocumentId = ((selectedNode.ownerDocument as SDK.DOMModel.DOMDocument).id);
    const domPromise = selectedNode.domModel().classNamesPromise(ownerDocumentId).then(classes => {
      for (const className of classes) {
        completions.add(className);
      }
    });
    promises.push(domPromise);
    await Promise.all(promises);
    return [...completions];
  }
  private async buildClassNameCompletions(expression: string, prefix: string, force?: boolean):
      Promise<UI.SuggestBox.Suggestions> {
    if (!prefix || force) {
      this.classNamesPromise = null;
    }
    const selectedNode = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
    if (!selectedNode || (!prefix && !force && !expression.trim())) {
      return [];
    }
    if (!this.classNamesPromise || this.selectedFrameId !== selectedNode.frameId()) {
      this.classNamesPromise = this.getClassNames(selectedNode);
    }
    let completions: string[] = await this.classNamesPromise;
    const classesMap = this.nodeClasses((selectedNode));
    completions = completions.filter(value => !classesMap.get(value));
    if (prefix[0] === '.') {
      completions = completions.map(value => '.' + value);
    }
    return completions.filter(value => value.startsWith(prefix)).sort().map(completion => {
      return {
        text: completion,
        title: undefined,
        subtitle: undefined,
        priority: undefined,
        isSecondary: undefined,
        subtitleRenderer: undefined,
        selectionRange: undefined,
        hideGhostText: undefined,
        iconElement: undefined,
      };
    });
  }
}