chromium/chrome/browser/resources/bookmarks/app.ts

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/cr_components/managed_footnote/managed_footnote.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast_manager.js';
import 'chrome://resources/cr_elements/cr_splitter/cr_splitter.js';
import './folder_node.js';
import './list.js';
import './router.js';
import './shared_vars.css.js';
import './strings.m.js';
import './command_manager.js';
import './toolbar.js';

import type {CrSplitterElement} from 'chrome://resources/cr_elements/cr_splitter/cr_splitter.js';
import type {FindShortcutMixinInterface} from 'chrome://resources/cr_elements/find_shortcut_mixin.js';
import {FindShortcutMixin} from 'chrome://resources/cr_elements/find_shortcut_mixin.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {IronScrollTargetBehavior} from 'chrome://resources/polymer/v3_0/iron-scroll-target-behavior/iron-scroll-target-behavior.js';
import {mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {setSearchResults} from './actions.js';
import {destroy as destroyApiListener, init as initApiListener} from './api_listener.js';
import {getTemplate} from './app.html.js';
import {BookmarksApiProxyImpl} from './bookmarks_api_proxy.js';
import {LOCAL_STORAGE_FOLDER_STATE_KEY, LOCAL_STORAGE_TREE_WIDTH_KEY, ROOT_NODE_ID} from './constants.js';
import {DndManager} from './dnd_manager.js';
import type {MouseFocusMixinInterface} from './mouse_focus_behavior.js';
import {MouseFocusMixin} from './mouse_focus_behavior.js';
import {Store} from './store.js';
import type {StoreClientMixinInterface} from './store_client_mixin.js';
import {StoreClientMixin} from './store_client_mixin.js';
import type {BookmarksToolbarElement} from './toolbar.js';
import type {BookmarksPageState, FolderOpenState} from './types.js';
import {createEmptyState, normalizeNodes} from './util.js';

const BookmarksAppElementBase =
    mixinBehaviors(
        [IronScrollTargetBehavior],
        StoreClientMixin(MouseFocusMixin(FindShortcutMixin(PolymerElement)))) as
    {
      new (): PolymerElement & StoreClientMixinInterface &
          FindShortcutMixinInterface & IronScrollTargetBehavior &
          MouseFocusMixinInterface,
    };

export interface BookmarksAppElement {
  $: {
    splitter: CrSplitterElement,
    sidebar: HTMLDivElement,
  };
}

export class BookmarksAppElement extends BookmarksAppElementBase {
  static get is() {
    return 'bookmarks-app';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      searchTerm_: {
        type: String,
        observer: 'searchTermChanged_',
      },

      folderOpenState_: {
        type: Object,
        observer: 'folderOpenStateChanged_',
      },

      sidebarWidth_: String,

      toolbarShadow_: {
        type: Boolean,
        reflectToAttribute: true,
      },
    };
  }

  private eventTracker_: EventTracker = new EventTracker();
  private dndManager_: DndManager|null = null;
  private folderOpenState_: FolderOpenState;
  private searchTerm_: string;
  private sidebarWidth_: string;
  private toolbarShadow_: boolean;

  constructor() {
    super();

    // Regular expression that captures the leading slash, the content and the
    // trailing slash in three different groups.
    const CANONICAL_PATH_REGEX = /(^\/)([\/-\w]+)(\/$)/;
    const path = location.pathname.replace(CANONICAL_PATH_REGEX, '$1$2');
    if (path !== '/') {  // Only queries are supported, not subpages.
      window.history.replaceState(undefined /* stateObject */, '', '/');
    }
  }

  override connectedCallback() {
    super.connectedCallback();

    document.documentElement.classList.remove('loading');

    this.watch('searchTerm_', function(state: BookmarksPageState) {
      return state.search.term;
    });

    this.watch('folderOpenState_', function(state: BookmarksPageState) {
      return state.folderOpenState;
    });

    BookmarksApiProxyImpl.getInstance().getTree().then((results) => {
      const nodeMap = normalizeNodes(results[0]!);
      const initialState = createEmptyState();
      initialState.nodes = nodeMap;
      initialState.selectedFolder = nodeMap[ROOT_NODE_ID]!.children![0]!;
      const folderStateString =
          window.localStorage[LOCAL_STORAGE_FOLDER_STATE_KEY];
      initialState.folderOpenState = folderStateString ?
          new Map(JSON.parse(folderStateString)) :
          new Map();

      Store.getInstance().init(initialState);
      initApiListener();

      setTimeout(function() {
        chrome.metricsPrivate.recordTime(
            'BookmarkManager.ResultsRenderedTime',
            Math.floor(window.performance.now()));
      });
    });

    this.initializeSplitter_();

    this.dndManager_ = new DndManager();
    this.dndManager_.init();

    this.scrollTarget = this.shadowRoot!.querySelector('bookmarks-list');
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    this.eventTracker_.remove(window, 'resize');
    this.dndManager_!.destroy();
    destroyApiListener();
  }

  private initializeSplitter_(): void {
    const splitter = this.$.splitter;
    const splitterTarget = this.$.sidebar;

    // The splitter persists the size of the left component in the local store.
    if (LOCAL_STORAGE_TREE_WIDTH_KEY in window.localStorage) {
      splitterTarget.style.width =
          window.localStorage[LOCAL_STORAGE_TREE_WIDTH_KEY];
    }
    this.sidebarWidth_ = getComputedStyle(splitterTarget).width;

    splitter.addEventListener('resize', (_e: Event) => {
      window.localStorage[LOCAL_STORAGE_TREE_WIDTH_KEY] =
          splitterTarget.style.width;
      this.updateSidebarWidth_();
    });

    this.eventTracker_.add(splitter, 'dragmove',
                           () => this.updateSidebarWidth_());
    this.eventTracker_.add(window, 'resize', () => this.updateSidebarWidth_());
  }

  private updateSidebarWidth_(): void {
    this.sidebarWidth_ = getComputedStyle(this.$.sidebar).width;
  }

  private searchTermChanged_(newValue: string, oldValue?: string) {
    if (oldValue !== undefined && !newValue) {
      this.dispatchEvent(new CustomEvent('iron-announce', {
        bubbles: true,
        composed: true,
        detail: {text: loadTimeData.getString('searchCleared')},
      }));
    }

    if (!this.searchTerm_) {
      return;
    }

    BookmarksApiProxyImpl.getInstance()
        .search(this.searchTerm_)
        .then(results => {
          const ids = results.map(node => node.id);
          this.dispatch(setSearchResults(ids));
          this.dispatchEvent(new CustomEvent('iron-announce', {
            bubbles: true,
            composed: true,
            detail: {
              text: ids.length > 0 ?
                  loadTimeData.getStringF('searchResults', this.searchTerm_) :
                  loadTimeData.getString('noSearchResults'),
            },
          }));
        });
  }

  private folderOpenStateChanged_(): void {
    window.localStorage[LOCAL_STORAGE_FOLDER_STATE_KEY] =
        JSON.stringify(Array.from(this.folderOpenState_));
  }

  // Override FindShortcutMixin methods.
  override handleFindShortcut(modalContextOpen: boolean): boolean {
    if (modalContextOpen) {
      return false;
    }
    this.shadowRoot!.querySelector<BookmarksToolbarElement>(
        'bookmarks-toolbar')!.searchField.showAndFocus();
    return true;
  }

  // Override FindShortcutMixin methods.
  override searchInputHasFocus(): boolean {
    return this.shadowRoot!.querySelector<BookmarksToolbarElement>(
        'bookmarks-toolbar')!.searchField.isSearchFocused();
  }

  private onUndoClick_(): void {
    this.dispatchEvent(
        new CustomEvent('command-undo', {bubbles: true, composed: true}));
  }

  /** Overridden from IronScrollTargetBehavior */
  /* eslint-disable-next-line @typescript-eslint/naming-convention */
  override _scrollHandler() {
    this.toolbarShadow_ = this.scrollTarget!.scrollTop !== 0;
  }

  getDndManagerForTesting(): DndManager|null {
    return this.dndManager_;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'bookmarks-app': BookmarksAppElement;
  }
}

customElements.define(BookmarksAppElement.is, BookmarksAppElement);