chromium/ui/file_manager/file_manager/foreground/js/ui/list_container.ts

// 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 {dispatchSimpleEvent} from 'chrome://resources/ash/common/cr_deprecated.js';
import {assert, assertInstanceof, assertNotReached} from 'chrome://resources/js/assert.js';

import {queryRequiredElement} from '../../../common/js/dom_utils.js';
import {DialogType} from '../../../state/state.js';
import type {FileListModel} from '../file_list_model.js';
import {GROUP_BY_FIELD_DIRECTORY, GROUP_BY_FIELD_MODIFICATION_TIME} from '../file_list_model.js';
import type {ListThumbnailLoader} from '../list_thumbnail_loader.js';

import type {FileGrid} from './file_grid.js';
import type {FileListSelectionModel, FileListSingleSelectionModel} from './file_list_selection_model.js';
import type {FileTable} from './file_table.js';
import type {List} from './list.js';
import type {ListItem} from './list_item.js';
import {ListSelectionModel} from './list_selection_model.js';

class TextSearchState {
  text: string = '';
  date: Date = new Date();
}

/**
 * List container for the file table and the grid view.
 */
export class ListContainer {
  currentListType: ListType = ListType.UNINITIALIZED;

  /**
   * The input element to rename entry.
   */
  readonly renameInput: HTMLInputElement;

  /**
   * Spinner on file list which is shown while loading.
   */
  readonly spinner: HTMLElement;

  dataModel: FileListModel|null = null;
  listThumbnailLoader: ListThumbnailLoader|null = null;
  selectionModel: FileListSelectionModel|FileListSingleSelectionModel|null =
      null;

  /**
   * Data model which is used as a placefolder in inactive file list. This is
   * set by FileManager.
   */
  emptyDataModel: FileListModel|null = null;

  /**
   * Selection model which is used as a placefolder in inactive file list.
   */
  private readonly emptySelectionModel_ = new ListSelectionModel();

  readonly textSearchState = new TextSearchState();

  /**
   * Whtehter to allow or cancel a context menu event.
   */
  private allowContextMenuByTouch_ = false;

  /**
   * List container needs to know if the current active directory is Recent
   * or not so it can update groupBy filed accordingly.
   */
  isOnRecent = false;

  /**
   * @param element The container element of the file list.
   * @param table File table.
   * @param grid File grid.
   * @param type The type of the main dialog.
   */
  constructor(
      public readonly element: HTMLElement, public readonly table: FileTable,
      public readonly grid: FileGrid, type: DialogType) {
    this.renameInput = document.createElement('input');
    assertInstanceof(this.renameInput, HTMLInputElement);
    this.renameInput.className = 'rename entry-name';
    this.spinner =
        queryRequiredElement('files-spinner.loading-indicator', element);

    // Overriding the default role 'list' to 'listbox' for better accessibility
    // on ChromeOS.
    this.table.list.setAttribute('role', 'listbox');
    this.table.list.id = 'file-list';
    this.grid.setAttribute('role', 'listbox');
    this.grid.id = 'file-list';

    // Ensure the list and grid are marked ARIA single select for save as.
    if (type === DialogType.SELECT_SAVEAS_FILE) {
      const list = table.querySelector('#file-list');
      list?.setAttribute('aria-multiselectable', 'false');
      grid.setAttribute('aria-multiselectable', 'false');
    }
  }

  /**
   * Avoid adding event listeners in the constructor because the ListContainer
   * isn't fully usable until setCurrentListType() is called.
   */
  private addEventListeners_() {
    this.element.addEventListener('keydown', this.onKeyDown_.bind(this));
    this.element.addEventListener('keypress', this.onKeyPress_.bind(this));
    this.element.addEventListener(
        'contextmenu', this.onContextMenu_.bind(this), /* useCapture */ true);

    // Disables context menu by long-tap when long-tap would transition to
    // multi-select mode, but keep it enabled for two-finger tap.
    this.element.addEventListener('touchstart', (e: TouchEvent) => {
      if (e.touches.length > 1 || this.currentList.selectedItem) {
        this.allowContextMenuByTouch_ = true;
      }
    }, {passive: true});
    this.element.addEventListener('touchend', (e: TouchEvent) => {
      if (e.touches.length === 0) {
        // contextmenu event will be sent right after touchend.
        setTimeout(() => this.allowContextMenuByTouch_ = false);
      }
    });
  }

  get currentView(): FileTable|FileGrid {
    switch (this.currentListType) {
      case ListType.DETAIL:
        return this.table;
      case ListType.THUMBNAIL:
        return this.grid;
    }
    assertNotReached();
  }

  get currentList(): List {
    switch (this.currentListType) {
      case ListType.DETAIL:
        return this.table.list;
      case ListType.THUMBNAIL:
        return this.grid;
    }
    assertNotReached();
  }

  /**
   * Notifies beginning of batch update to the UI.
   */
  startBatchUpdates() {
    this.table.startBatchUpdates();
    this.grid.startBatchUpdates();
  }

  /**
   * Notifies end of batch update to the UI.
   */
  endBatchUpdates() {
    this.table.endBatchUpdates();
    this.grid.endBatchUpdates();
  }

  /**
   * Sets the current list type.
   * @param listType New list type.
   */
  setCurrentListType(listType: ListType) {
    assert(this.dataModel);
    assert(this.selectionModel);

    this.addEventListeners_();

    this.startBatchUpdates();
    this.currentListType = listType;

    this.element.classList.toggle('list-view', listType === ListType.DETAIL);
    this.element.classList.toggle(
        'thumbnail-view', listType === ListType.THUMBNAIL);

    // TODO(dzvorygin): style.display and dataModel setting order shouldn't
    // cause any UI bugs. Currently, the only right way is first to set display
    // style and only then set dataModel.
    // Always sharing the data model between the detail/thumb views confuses
    // them.  Instead we maintain this bogus data model, and hook it up to the
    // view that is not in use.
    switch (listType) {
      case ListType.DETAIL:
        this.dataModel.groupByField =
            this.isOnRecent ? GROUP_BY_FIELD_MODIFICATION_TIME : null;
        this.table.dataModel = this.dataModel;
        this.table.setListThumbnailLoader(this.listThumbnailLoader);
        this.table.selectionModel = this.selectionModel;
        this.table.hidden = false;
        this.grid.hidden = true;
        this.grid.selectionModel = this.emptySelectionModel_;
        this.grid.setListThumbnailLoader(null);
        this.grid.dataModel = this.emptyDataModel;
        break;

      case ListType.THUMBNAIL:
        if (this.isOnRecent) {
          this.dataModel.groupByField = GROUP_BY_FIELD_MODIFICATION_TIME;
        } else {
          this.dataModel.groupByField = GROUP_BY_FIELD_DIRECTORY;
        }
        this.grid.dataModel = this.dataModel;
        this.grid.setListThumbnailLoader(this.listThumbnailLoader);
        this.grid.selectionModel = this.selectionModel;
        this.grid.hidden = false;
        this.table.hidden = true;
        this.table.selectionModel = this.emptySelectionModel_;
        this.table.setListThumbnailLoader(null);
        this.table.dataModel = this.emptyDataModel;
        break;

      default:
        assertNotReached();
    }
    this.endBatchUpdates();
  }

  /**
   * Finds list item element from the ancestor node.
   */
  findListItemForNode(node: HTMLElement): ListItem|null {
    const item = this.currentList.getListItemAncestor(node);
    return item && this.currentList.isItem(item) ? item : null;
  }

  /**
   * Focuses the active file list in the list container.
   */
  focus() {
    switch (this.currentListType) {
      case ListType.DETAIL:
        this.table.list.focus();
        break;
      case ListType.THUMBNAIL:
        this.grid.focus();
        break;
      default:
        assertNotReached();
    }
  }

  /**
   * Contextmenu event handler to prevent change of focus on long-tapping the
   * header of the file list.
   * @param e Menu event.
   */
  private onContextMenu_(e: Event) {
    // sourceCapabilities isn't defined in TS, because it's experimental.
    const sourceCapabilities = (e as any).sourceCapabilities;

    // Block context menu triggered by touch event unless either:
    // - It is right after a multi-touch, or
    // - We were already in multi-select mode, or
    // - No items are selected (i.e. long-tap on empty area in the current
    // folder).
    if (this.currentList.selectedItem && !this.allowContextMenuByTouch_ &&
        sourceCapabilities && sourceCapabilities.firesTouchEvents) {
      e.stopPropagation();
      e.preventDefault();
    }
    if (!this.allowContextMenuByTouch_ && sourceCapabilities &&
        sourceCapabilities.firesTouchEvents) {
      this.focus();
    }
  }

  /**
   * KeyDown event handler for the div#list-container element.
   * @param event Key event.
   */
  private onKeyDown_(event: Event) {
    // Ignore keydown handler in the rename input box.
    const srcElement = event.srcElement as HTMLElement | null;
    if (srcElement?.tagName === 'INPUT') {
      event.stopImmediatePropagation();
      return;
    }
  }

  /**
   * KeyPress event handler for the div#list-container element.
   * @param event Key event.
   */
  private onKeyPress_(event: KeyboardEvent) {
    const srcElement = event.srcElement as HTMLElement | null;

    // Ignore keypress handler in the rename input box.
    if (srcElement?.tagName === 'INPUT' || event.ctrlKey || event.metaKey ||
        event.altKey) {
      event.stopImmediatePropagation();
      return;
    }

    const now = new Date();
    const character = String.fromCharCode(event.charCode).toLowerCase();
    const text = Number(now) - Number(this.textSearchState.date) > 1000 ?
        '' :
        this.textSearchState.text;
    this.textSearchState.text = text + character;
    this.textSearchState.date = now;

    if (this.textSearchState.text) {
      dispatchSimpleEvent(this.element, EventType.TEXT_SEARCH);
    }
  }
}

export enum EventType {
  TEXT_SEARCH = 'textsearch',
}

export enum ListType {
  UNINITIALIZED = 'uninitialized',
  DETAIL = 'detail',
  THUMBNAIL = 'thumb',
}

/**
 * Keep the order of this in sync with FileManagerListType in
 * tools/metrics/histograms/enums.xml.
 * The array indices will be recorded in UMA as enum values. The index for each
 * root type should never be renumbered nor reused in this array.
 */
export const ListTypesForUMA = Object.freeze([
  ListType.UNINITIALIZED,
  ListType.DETAIL,
  ListType.THUMBNAIL,
]);
console.assert(
    Object.keys(ListType).length === ListTypesForUMA.length,
    'Members in ListTypesForUMA do not match those in ListType.');