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

// Copyright 2012 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} from 'chrome://resources/js/assert.js';

import type {VolumeManager} from '../../../background/js/volume_manager.js';
import {RateLimiter} from '../../../common/js/async_util.js';
import {crInjectTypeAndInit} from '../../../common/js/cr_ui.js';
import {maybeShowTooltip} from '../../../common/js/dom_utils.js';
import {entriesToURLs, isTeamDriveRoot} from '../../../common/js/entry_utils.js';
import {getType, isAudio, isEncrypted, isImage, isPDF, isRaw, isVideo} from '../../../common/js/file_type.js';
import type {FilesAppEntry} from '../../../common/js/files_app_entry_types.js';
import {isDlpEnabled} from '../../../common/js/flags.js';
import {getEntryLabel, str, strf} from '../../../common/js/translations.js';
import {FileListModel, GROUP_BY_FIELD_MODIFICATION_TIME} from '../file_list_model.js';
import type {ListThumbnailLoader} from '../list_thumbnail_loader.js';
import {type ThumbnailLoadedEvent} from '../list_thumbnail_loader.js';
import type {MetadataModel} from '../metadata/metadata_model.js';

import type {A11yAnnounce} from './a11y_announce.js';
import {DragSelector} from './drag_selector.js';
import {FileMetadataFormatter} from './file_metadata_formatter.js';
import {decorateListItem, FileTableList, isDlpBlocked, renderFileNameLabel, renderFileTypeIcon, renderIconBadge, updateCacheItemInlineStatus, updateListItemExternalProps} from './file_table_list.js';
import type {ListItem} from './list_item.js';
import {Table} from './table/table.js';
import {TableColumn} from './table/table_column.js';
import {TableColumnModel} from './table/table_column_model.js';


type ColumnWidthConfig = Record<string, {width: number}>;

interface ColumnHitResult {
  index: number;
  width: number;
  hitPosition: number;
}

/**
 * Custom column model for advanced auto-resizing.
 */
export class FileTableColumnModel extends TableColumnModel {
  private snapshot_: ColumnSnapshot|null = null;

  /**
   * Sets column width so that the column dividers move to the specified
   * position. This function also check the width of each column and keep the
   * width larger than MIN_WIDTH.
   *
   * @param newPos Positions of each column dividers.
   */
  private applyColumnPositions_(newPos: number[]) {
    // Check the minimum width and adjust the positions.
    for (let i = 0; i < newPos.length - 2; i++) {
      if (!this.columns_[i]!.visible) {
        newPos[i + 1] = newPos[i]!;
      } else if (newPos[i + 1]! - newPos[i]! < MIN_WIDTH) {
        newPos[i + 1] = newPos[i]! + MIN_WIDTH;
      }
    }
    for (let i = newPos.length - 1; i >= 2; i--) {
      if (!this.columns_[i - 1]!.visible) {
        newPos[i - 1] = newPos[i]!;
      } else if (newPos[i]! - newPos[i - 1]! < MIN_WIDTH) {
        newPos[i - 1] = newPos[i]! - MIN_WIDTH;
      }
    }
    // Set the new width of columns
    for (let i = 0; i < this.columns_.length; i++) {
      if (!this.columns_[i]!.visible) {
        this.columns_[i]!.width = 0;
      } else {
        // Make sure each cell has the minimum width. This is necessary when the
        // window size is too small to contain all the columns.
        this.columns_[i]!.width =
            Math.max(MIN_WIDTH, newPos[i + 1]! - newPos[i]!);
      }
    }
  }

  /**
   * Normalizes widths to make their sum 100% if possible. Uses the proportional
   * approach with some additional constraints.
   *
   * @param contentWidth Target width.
   */
  override normalizeWidths(contentWidth: number) {
    let totalWidth = 0;
    // Some columns have fixed width.
    for (let i = 0; i < this.columns_.length; i++) {
      totalWidth += this.columns_[i]!.width;
    }
    const positions = [0];
    let sum = 0;
    for (let i = 0; i < this.columns_.length; i++) {
      const column = this.columns_[i]!;
      sum += column.width;
      // Faster alternative to Math.floor for non-negative numbers.
      positions[i + 1] = ~~(contentWidth * sum / totalWidth);
    }
    this.applyColumnPositions_(positions);
  }

  /**
   * Handles to the start of column resizing by splitters.
   */
  handleSplitterDragStart() {
    this.initializeColumnPos();
  }

  /**
   * Handles to the end of column resizing by splitters.
   */
  handleSplitterDragEnd() {
    this.destroyColumnPos();
  }

  /**
   * Initialize a column snapshot which is used in setWidthAndKeepTotal().
   */
  initializeColumnPos() {
    this.snapshot_ = new ColumnSnapshot(this.columns_);
  }

  /**
   * Destroy the column snapshot which is used in setWidthAndKeepTotal().
   */
  destroyColumnPos() {
    this.snapshot_ = null;
  }

  /**
   * Sets the width of column while keeping the total width of table.
   * Before and after calling this method, you must initialize and destroy
   * columnPos with initializeColumnPos() and destroyColumnPos().
   * @param columnIndex Index of column that is resized.
   * @param columnWidth New width of the column.
   */
  setWidthAndKeepTotal(columnIndex: number, columnWidth: number) {
    columnWidth = Math.max(columnWidth, MIN_WIDTH);
    this.snapshot_!.setWidth(columnIndex, columnWidth);
    this.applyColumnPositions_(this.snapshot_!.newPos);

    // Notify about resizing
    dispatchSimpleEvent(this, 'resize');
  }

  /**
   * Obtains a column by the specified horizontal position.
   * @param x Horizontal position.
   * @return The object that contains column index, column width, and
   *     hitPosition where the horizontal position is hit in the column.
   */
  getHitColumn(x: number): null|ColumnHitResult {
    let i = 0;
    for (; i < this.columns_.length && x >= this.columns_[i]!.width; i++) {
      x -= this.columns_[i]!.width;
    }
    if (i >= this.columns_.length) {
      return null;
    }
    return {index: i, hitPosition: x, width: this.columns_[i]!.width};
  }

  override setVisible(index: number, visible: boolean) {
    if (index < 0 || index > this.columns_.length - 1) {
      return;
    }

    const column = this.columns_[index]!;
    if (column.visible === visible) {
      return;
    }

    // Re-layout the table.  This overrides the default column layout code in
    // the parent class.
    const snapshot = new ColumnSnapshot(this.columns_);

    column.visible = visible;

    // Keep the current column width, but adjust the other columns to
    // accommodate the new column.
    snapshot.setWidth(index, column.width);
    this.applyColumnPositions_(snapshot.newPos);
  }

  /**
   * Export a set of column widths for use by #restoreColumnWidths.  Use these
   * two methods instead of manually saving and setting column widths, because
   * doing the latter will not correctly save/restore column widths for hidden
   * columns.
   * see #restoreColumnWidths
   * @return config
   */
  exportColumnConfig(): ColumnWidthConfig {
    // Make a snapshot, and use that to compute a column layout where all the
    // columns are visible.
    const snapshot = new ColumnSnapshot(this.columns_);
    for (let i = 0; i < this.columns_.length; i++) {
      if (!this.columns_[i]!.visible) {
        snapshot.setWidth(i, this.columns_[i]!.absoluteWidth);
      }
    }
    // Export the column widths.
    const config: ColumnWidthConfig = {};
    for (let i = 0; i < this.columns_.length; i++) {
      config[this.columns_[i]!.id] = {
        width: snapshot.newPos[i + 1]! - snapshot.newPos[i]!,
      };
    }
    return config;
  }

  /**
   * Restores a set of column widths previously created by calling
   * #exportColumnConfig.
   * see #exportColumnConfig
   */
  restoreColumnConfig(config: ColumnWidthConfig) {
    // Columns must all be made visible before restoring their widths.  Save the
    // current visibility so it can be restored after.
    const visibility = [];
    for (let i = 0; i < this.columns_.length; i++) {
      visibility[i] = this.columns_[i]!.visible;
      this.columns_[i]!.visible = true;
    }

    // Do not use external setters (e.g. #setVisible, #setWidth) here because
    // they trigger layout thrash, and also try to dynamically resize columns,
    // which interferes with restoring the old column layout.
    for (const columnId in config) {
      const column = this.columns_[this.indexOf(columnId)];
      if (column) {
        // Set column width.  Ignore invalid widths.
        const width = ~~config[columnId]!.width;
        if (width > 0) {
          column.width = width;
        }
      }
    }

    // Restore column visibility.  Use setVisible here, to trigger table
    // relayout.
    for (let i = 0; i < this.columns_.length; i++) {
      this.setVisible(i, visibility[i]!);
    }
  }
}

/**
 * Customize the column header to decorate with a11y attributes that announces
 * the sorting used when clicked.
 *
 * @this {TableColumn} Bound by TableHeader before calling.
 * @param table Table being rendered.
 */
function renderHeader(this: TableColumn, table: FileTable): Element {
  const column = this;
  const container = table.ownerDocument.createElement('div');
  container.classList.add('table-label-container');

  const textElement = table.ownerDocument.createElement('span');
  textElement.setAttribute('id', `column-${column.id}`);
  textElement.textContent = column.name;
  const dm = table.dataModel;

  let sortOrder = column.defaultOrder;
  let isSorted = false;
  if (dm && dm.sortStatus.field === column.id) {
    isSorted = true;
    // Here we have to flip, because clicking will perform the opposite sorting.
    sortOrder = dm.sortStatus.direction === 'desc' ? 'asc' : 'desc';
  }

  textElement.setAttribute('aria-describedby', 'sort-column-' + sortOrder);
  textElement.setAttribute('role', 'button');
  container.appendChild(textElement);

  const icon = document.createElement('cr-icon-button');
  const iconName = sortOrder === 'desc' ? 'up' : 'down';
  icon.setAttribute('iron-icon', `files16:arrow_${iconName}_small`);
  icon.role = 'button';
  // If we're the sorting column make the icon a tab target.
  if (isSorted) {
    icon.id = 'sort-direction-button';
    icon.setAttribute('tabindex', '0');
    icon.setAttribute('aria-hidden', 'false');
    if (sortOrder === 'asc') {
      icon.setAttribute('aria-label', str('COLUMN_ASC_SORT_MESSAGE'));
    } else {
      icon.setAttribute('aria-label', str('COLUMN_DESC_SORT_MESSAGE'));
    }
  } else {
    icon.setAttribute('tabindex', '-1');
    icon.setAttribute('aria-hidden', 'true');
  }
  icon.classList.add('sort-icon', 'no-overlap');

  container.classList.toggle('not-sorted', !isSorted);
  container.classList.toggle('sorted', isSorted);

  container.appendChild(icon);

  return container;
}

/**
 * Minimum width of column. Note that is not marked private as it is used in the
 * unit tests.
 */
export const MIN_WIDTH = 40;

/**
 * A helper class for performing resizing of columns.
 */
class ColumnSnapshot {
  private columnPos_: number[];
  newPos: number[];

  /**
   */
  constructor(columns: TableColumn[]) {
    this.columnPos_ = [0];
    for (let i = 0; i < columns.length; i++) {
      this.columnPos_[i + 1] = columns[i]!.width + this.columnPos_[i]!;
    }

    /**
     * Starts off as a copy of the current column positions, but gets modified.
     */
    this.newPos = this.columnPos_.slice(0);
  }

  /**
   * Set the width of the given column.  The snapshot will keep the total width
   * of the table constant.
   */
  setWidth(index: number, width: number) {
    // Skip to resize 'selection' column
    if (index < 0 || index >= this.columnPos_.length - 1 || !this.columnPos_) {
      return;
    }

    // Round up if the column is shrinking, and down if the column is expanding.
    // This prevents off-by-one drift.
    const currentWidth = this.columnPos_[index + 1]! - this.columnPos_[index]!;
    const round = width < currentWidth ? Math.ceil : Math.floor;

    // Calculate new positions of column splitters.
    const newPosStart = this.columnPos_[index]! + width;
    const posEnd = this.columnPos_[this.columnPos_.length - 1]!;
    for (let i = 0; i < index + 1; i++) {
      this.newPos[i] = this.columnPos_[i]!;
    }
    for (let i = index + 1; i < this.columnPos_.length - 1; i++) {
      const posStart = this.columnPos_[index + 1]!;
      this.newPos[i] = (posEnd - newPosStart) *
              (this.columnPos_[i]! - posStart) / (posEnd - posStart) +
          newPosStart;
      this.newPos[i] = round(this.newPos[i]!);
    }
    this.newPos[index] = this.columnPos_[index]!;
    this.newPos[this.columnPos_.length - 1] = posEnd;
  }
}

/**
 * File list Table View.
 */
export class FileTable extends Table {
  private beginIndex_: number = 0;
  private endIndex_: number = 0;
  private listThumbnailLoader_: ListThumbnailLoader|null = null;
  private relayoutRateLimiter_: RateLimiter|null = null;
  private metadataModel_: MetadataModel|null = null;
  private formatter_: FileMetadataFormatter|null = null;
  private useModificationByMeTime_: boolean = false;
  private volumeManager_: VolumeManager|null = null;
  private lastSelection_: unknown[] = [];
  private onThumbnailLoadedBound_: null|EventListener = null;
  a11y: A11yAnnounce|null = null;

  /**
   * Decorates the element.
   * @param self Table to decorate.
   * @param metadataModel To retrieve metadata.
   * @param volumeManager To retrieve volume info.
   * @param a11y FileManagerUI to be able to announce a11y
   *     messages.
   * @param fullPage True if it's full page File Manager, False if a
   *    file open/save dialog.
   */
  static decorate(
      el: HTMLElement, metadataModel: MetadataModel,
      volumeManager: VolumeManager, a11y: A11yAnnounce, fullPage: boolean) {
    crInjectTypeAndInit(el, Table);
    Object.setPrototypeOf(el, FileTable.prototype);
    const self = el as FileTable;
    crInjectTypeAndInit(self.list, FileTableList);
    const list = self.list as FileTableList;
    list.setOnMergeItems(self.updateHighPriorityRange_.bind(self));
    self.metadataModel_ = metadataModel;
    self.volumeManager_ = volumeManager;
    self.a11y = a11y;

    // Force the list's ending spacer to be tall enough to allow overscroll.
    const endSpacer = self.querySelector('.spacer:last-child');
    if (endSpacer) {
      endSpacer.classList.add('signals-overscroll');
    }

    self.listThumbnailLoader_ = null;
    self.beginIndex_ = 0;
    self.endIndex_ = 0;
    self.onThumbnailLoadedBound_ =
        self.onThumbnailLoaded_.bind(self) as EventListener;

    self.useModificationByMeTime_ = false;

    const nameColumn =
        new TableColumn('name', str('NAME_COLUMN_LABEL'), fullPage ? 386 : 324);
    nameColumn.renderFunction = self.renderName_.bind(self);
    nameColumn.headerRenderFunction = renderHeader;

    const sizeColumn =
        new TableColumn('size', str('SIZE_COLUMN_LABEL'), 110, false);
    sizeColumn.renderFunction = self.renderSize_.bind(self);
    sizeColumn.defaultOrder = 'desc';
    sizeColumn.headerRenderFunction = renderHeader;

    const typeColumn =
        new TableColumn('type', str('TYPE_COLUMN_LABEL'), fullPage ? 110 : 110);
    typeColumn.renderFunction = self.renderType_.bind(self);
    typeColumn.headerRenderFunction = renderHeader;

    const modTimeColumn = new TableColumn(
        'modificationTime', str('DATE_COLUMN_LABEL'), fullPage ? 150 : 210);
    modTimeColumn.renderFunction = self.renderDate_.bind(self);
    modTimeColumn.defaultOrder = 'desc';
    modTimeColumn.headerRenderFunction = renderHeader;

    const columns = [nameColumn, sizeColumn, typeColumn, modTimeColumn];

    const columnModel = new FileTableColumnModel(columns);

    self.columnModel = columnModel;

    self.formatter_ = new FileMetadataFormatter();

    self.setRenderFunction(
        self.renderTableRow_.bind(self, self.getRenderFunction()));

    // Keep focus on the file list when clicking on the header.
    self.header.addEventListener('mousedown', e => {
      self.list.focus();
      e.preventDefault();
    });

    self.relayoutRateLimiter_ =
        new RateLimiter(self.relayoutImmediately_.bind(self));

    // Save the last selection. This is used by shouldStartDragSelection.
    self.list.addEventListener('mousedown', (_e: Event) => {
      self.lastSelection_ = self.selectionModel.selectedIndexes;
    }, true);
    self.list.addEventListener('touchstart', (_e: Event) => {
      self.lastSelection_ = self.selectionModel.selectedIndexes;
    }, true);
    list.shouldStartDragSelection = self.shouldStartDragSelection_.bind(self);

    list.addEventListener(
        'mouseover', self.onMouseOver_.bind(self), {passive: true});

    // Update the item's inline status when it's restored from List's cache.
    list.addEventListener(
        'cachedItemRestored',
        (e) => updateCacheItemInlineStatus(
            e.detail, self.dataModel, self.metadataModel_!));
  }

  private onMouseOver_(event: MouseEvent) {
    this.maybeShowToolTip(event);
  }

  maybeShowToolTip(event: MouseEvent) {
    const target = event.composedPath()[0] as HTMLElement;
    if (!target) {
      return;
    }
    if (!target.classList.contains('detail-name')) {
      return;
    }
    const labelElement = target.querySelector<HTMLElement>('.filename-label');
    if (!labelElement) {
      return;
    }

    maybeShowTooltip(labelElement, labelElement.innerText);
  }

  /**
   * Sort data by the given column. Overridden to add the a11y message after
   * sorting.
   * @param index The index of the column to sort by.
   */
  override sort(index: number) {
    const cm = this.columnModel;
    if (!this.dataModel) {
      return;
    }
    const fieldName = cm.getId(index);
    const sortStatus = this.dataModel.sortStatus;

    let sortDirection = cm.getDefaultOrder(index);
    if (sortStatus.field === fieldName) {
      // If it's sorting the column that's already sorted, we need to flip the
      // sorting order.
      sortDirection = sortStatus.direction === 'desc' ? 'asc' : 'desc';
    }

    const msgId =
        sortDirection === 'asc' ? 'COLUMN_SORTED_ASC' : 'COLUMN_SORTED_DESC';
    const msg = strf(msgId, fieldName);

    // Delegate to parent to sort.
    super.sort(index);
    this.a11y!.speakA11yMessage(msg);
  }

  /**
   */
  override onDataModelSorted() {
    const fileListModel = this.dataModel as FileListModel;
    const hasGroupHeadingAfterSort = fileListModel.shouldShowGroupHeading();
    // Sort doesn't trigger redraw sometimes, e.g. if we sort by Name for now,
    // then we sort by time, if the list order doesn't change, no permuted event
    // is triggered, thus no redraw is triggered. In this scenario, we need to
    // manually trigger a redraw to remove/add the group heading.
    if (hasGroupHeadingAfterSort !== fileListModel.hasGroupHeadingBeforeSort) {
      this.list.redraw();
    }
  }

  /**
   * Updates high priority range of list thumbnail loader based on current
   * viewport.
   *
   * @param beginIndex Begin index.
   * @param endIndex End index.
   */
  private updateHighPriorityRange_(beginIndex: number, endIndex: number) {
    // Keep these values to set range when a new list thumbnail loader is set.
    this.beginIndex_ = beginIndex;
    this.endIndex_ = endIndex;

    if (this.listThumbnailLoader_ !== null) {
      this.listThumbnailLoader_.setHighPriorityRange(beginIndex, endIndex);
    }
  }

  /**
   * Sets list thumbnail loader.
   * @param listThumbnailLoader A list thumbnail loader.
   */
  setListThumbnailLoader(listThumbnailLoader: ListThumbnailLoader|null) {
    if (this.listThumbnailLoader_) {
      this.listThumbnailLoader_.removeEventListener(
          'thumbnailLoaded', this.onThumbnailLoadedBound_);
    }

    this.listThumbnailLoader_ = listThumbnailLoader;

    if (this.listThumbnailLoader_) {
      this.listThumbnailLoader_.addEventListener(
          'thumbnailLoaded', this.onThumbnailLoadedBound_);
      this.listThumbnailLoader_.setHighPriorityRange(
          this.beginIndex_, this.endIndex_);
    }
  }

  /**
   * Returns the element containing the thumbnail of a certain list item as
   * background image.
   * @param index The index of the item containing the desired
   *     thumbnail.
   * @return The element containing the thumbnail, or null, if an
   *     error occurred.
   */
  getThumbnail(index: number): null|Element {
    const listItem = this.getListItemByIndex(index);
    if (!listItem) {
      return null;
    }
    const container = listItem.querySelector('.detail-thumbnail');
    if (!container) {
      return null;
    }
    return container.querySelector('.thumbnail');
  }

  /**
   * Handles thumbnail loaded event.
   */
  private onThumbnailLoaded_(event: ThumbnailLoadedEvent) {
    const listItem = this.getListItemByIndex(event.detail.index);
    if (listItem) {
      const box = listItem.querySelector<HTMLDivElement>('.detail-thumbnail');
      if (box) {
        if (event.detail.dataUrl) {
          this.setThumbnailImage_(box, event.detail.dataUrl);
        } else {
          this.clearThumbnailImage_(box);
        }
        const icon = listItem.querySelector<HTMLElement>('.detail-icon')!;
        icon.classList.toggle('has-thumbnail', !!event.detail.dataUrl);
      }
    }
  }

  /**
   * Adjust column width to fit its content.
   * @param index Index of the column to adjust width.
   */
  override fitColumn(index: number) {
    const render = this.columnModel.getRenderFunction(index);
    const MAXIMUM_ROWS_TO_MEASURE = 1000;
    assert(this.dataModel);

    // Create a temporaty list item, put all cells into it and measure its
    // width. Then remove the item. It fits "list > *" CSS rules.
    const container = this.ownerDocument.createElement('li');
    container.style.display = 'inline-block';
    container.style.textAlign = 'start';
    // The container will have width of the longest cell.
    container.style.webkitBoxOrient = 'vertical';

    // Select at most MAXIMUM_ROWS_TO_MEASURE items around visible area.
    const items = this.list.getItemsInViewPort(
        this.list.scrollTop, this.list.clientHeight);
    const firstIndex = Math.floor(
        Math.max(0, (items.last + items.first - MAXIMUM_ROWS_TO_MEASURE) / 2));
    const lastIndex =
        Math.min(this.dataModel.length, firstIndex + MAXIMUM_ROWS_TO_MEASURE);
    for (let i = firstIndex; i < lastIndex; i++) {
      const item = this.dataModel.item(i);
      const div = this.ownerDocument.createElement('div');
      div.className = 'table-row-cell';
      div.appendChild(render(item, this.columnModel.getId(index)!, this));
      container.appendChild(div);
    }
    this.list.appendChild(container);
    const width = parseFloat(window.getComputedStyle(container).width);
    this.list.removeChild(container);

    const cm = this.columnModel as FileTableColumnModel;
    cm.initializeColumnPos();
    cm.setWidthAndKeepTotal(index, Math.ceil(width));
    cm.destroyColumnPos();
  }

  /**
   * Sets date and time format.
   * @param use12hourClock True if 12 hours clock, False if 24 hours.
   */
  setDateTimeFormat(use12hourClock: boolean) {
    this.formatter_!.setDateTimeFormat(use12hourClock);
  }

  /**
   * Sets whether to use modificationByMeTime as "Last Modified" time.
   */
  setUseModificationByMeTime(useModificationByMeTime: boolean) {
    this.useModificationByMeTime_ = useModificationByMeTime;
  }

  /**
   * Obtains if the drag selection should be start or not by referring the mouse
   * event.
   * @param event Drag start event.
   * @return True if the mouse is hit to the background of the list,
   * or certain areas of the inside of the list that would start a drag
   * selection.
   */
  private shouldStartDragSelection_(event: MouseEvent): boolean {
    // If the shift key is pressed, it should starts drag selection.
    if (event.shiftKey) {
      return true;
    }

    const list = this.list as FileTableList;
    // If we're outside of the element list, start the drag selection.
    if (!list.hasDragHitElement(event)) {
      return true;
    }

    // If the position values are negative, it points the out of list.
    const pos = DragSelector.getScrolledPosition(this.list, event);
    if (!pos) {
      return false;
    }
    if (pos.x < 0 || pos.y < 0) {
      return true;
    }

    // If the item index is out of range, it should start the drag selection.
    const itemHeight = this.list.measureItem().height;
    // Faster alternative to Math.floor for non-negative numbers.
    const itemIndex = ~~(pos.y / itemHeight);
    const length = this.dataModel?.length ?? 0;
    if (itemIndex >= length) {
      return true;
    }

    // If the pointed item is already selected, it should not start the drag
    // selection.
    if (this.lastSelection_ && this.lastSelection_.indexOf(itemIndex) !== -1) {
      return false;
    }

    const cm = this.columnModel as FileTableColumnModel;
    // If the horizontal value is not hit to column, it should start the drag
    // selection.
    const hitColumn = cm.getHitColumn(pos.x);
    if (!hitColumn) {
      return true;
    }

    // Check if the point is on the column contents or not.
    switch (cm.getId(hitColumn.index)) {
      case 'name':
        const item = this.list.getListItemByIndex(itemIndex);
        if (!item) {
          return false;
        }

        const spanElement = item.querySelector('.filename-label span')!;
        const spanRect = spanElement && spanElement.getBoundingClientRect();
        // The `list.cachedBounds` is set by DragSelector.getScrolledPosition.
        if (!this.list.cachedBounds) {
          return true;
        }
        const textRight =
            spanRect.left - this.list.cachedBounds.left + spanRect.width;
        return textRight <= hitColumn.hitPosition;
      default:
        return true;
    }
  }

  /**
   * Render the Name column of the detail table.
   *
   * Invoked by Table when a file needs to be rendered.
   *
   * @param entry The Entry object to render.
   * @param _columnId The id of the column to be rendered.
   * @param _table The table doing the rendering.
   * @return Created element.
   */
  private renderName_(entry: Entry, _columnId: string, _table: Table):
      HTMLDivElement {
    const label = this.ownerDocument.createElement('div');

    const metadata = this.metadataModel_!.getCache(
        [entry], ['contentMimeType', 'isDlpRestricted'])[0]!;
    const mimeType = metadata.contentMimeType;
    const locationInfo = this.volumeManager_!.getLocationInfo(entry);
    const icon =
        renderFileTypeIcon(this.ownerDocument, entry, locationInfo, mimeType);
    if (isImage(entry, mimeType) || isPDF(entry, mimeType) ||
        isVideo(entry, mimeType) || isAudio(entry, mimeType) ||
        isRaw(entry, mimeType)) {
      icon.appendChild(this.renderThumbnail_(entry, icon));
    }
    icon.appendChild(this.renderCheckmark_());
    label.appendChild(icon);
    label.appendChild(renderIconBadge(this.ownerDocument));
    (label as HTMLDivElement & {entry: Entry}).entry = entry;
    label.className = 'detail-name';
    label.appendChild(
        renderFileNameLabel(this.ownerDocument, entry, locationInfo));
    if (locationInfo && locationInfo.isDriveBased) {
      const inlineStatus = this.ownerDocument.createElement('xf-inline-status');
      inlineStatus.classList.add('tast-inline-status');
      label.appendChild(inlineStatus);
    }
    return label;
  }

  /**
   * @param index Index of the list item.
   */
  getItemLabel(index: number): string {
    if (index === -1) {
      return '';
    }

    const entry = this.dataModel?.item(index) as Entry | FilesAppEntry;
    if (!entry) {
      return '';
    }

    const locationInfo = this.volumeManager_?.getLocationInfo(entry) || null;
    return getEntryLabel(locationInfo, entry);
  }

  /**
   * Render the Size column of the detail table.
   *
   * @param entry The Entry object to render.
   * @param _columnId The id of the column to be rendered.
   * @param _table The table doing the rendering.
   * @return Created element.
   */
  private renderSize_(entry: Entry, _columnId: string, _table: Table):
      HTMLDivElement {
    const div = this.ownerDocument.createElement('div');
    div.className = 'size';
    this.updateSize_(div, entry);

    return div;
  }

  /**
   * Sets up or updates the size cell.
   *
   * @param div The table cell.
   * @param entry The corresponding entry.
   */
  private updateSize_(div: HTMLElement, entry: Entry|FilesAppEntry) {
    const metadata = this.metadataModel_!.getCache(
        [entry], ['size', 'hosted', 'contentMimeType'])[0]!;
    const size = metadata.size;
    const special =
        metadata.hosted || isEncrypted(entry, metadata.contentMimeType);
    div.textContent = this.formatter_!.formatSize(size, special);
  }

  /**
   * Render the Type column of the detail table.
   *
   * @param entry The Entry object to render.
   * @param _columnId The id of the column to be rendered.
   * @param _table The table doing the rendering.
   * @return Created element.
   */
  private renderType_(entry: Entry, _columnId: string, _table: Table):
      HTMLDivElement {
    const div = this.ownerDocument.createElement('div');
    div.className = 'type';

    const mimeType =
        this.metadataModel_!.getCache(
                                [entry],
                                ['contentMimeType'])[0]!.contentMimeType;
    div.textContent = FileListModel.getFileTypeString(getType(entry, mimeType));
    return div;
  }

  /**
   * Render the Date column of the detail table.
   *
   * @param entry The Entry object to render.
   * @param _columnId The id of the column to be rendered.
   * @param _table The table doing the rendering.
   * @return Created element.
   */
  private renderDate_(entry: Entry, _columnId: string, _table: Table):
      HTMLDivElement {
    const div = this.ownerDocument.createElement('div');

    div.className = 'dateholder';
    const label = this.ownerDocument.createElement('div');
    div.appendChild(label);
    label.className = 'date';
    this.updateDate_(label, entry);
    const metadata = this.metadataModel_!.getCache(
        [entry], ['contentMimeType', 'isDlpRestricted'])[0]!;
    const encrypted = isEncrypted(entry, metadata.contentMimeType);
    if (encrypted) {
      div.appendChild(this.renderEncryptedIcon_());
    }
    if (isDlpEnabled()) {
      div.appendChild(this.renderDlpManagedIcon_(!!metadata.isDlpRestricted));
    }
    return div;
  }

  /**
   * Sets up or updates the date cell.
   *
   * @param div The table cell.
   * @param entry Entry of file to update.
   */
  private updateDate_(div: HTMLElement, entry: Entry|FilesAppEntry) {
    const item = this.metadataModel_!.getCache(
        [entry], ['modificationTime', 'modificationByMeTime'])[0]!;
    const modTime = this.useModificationByMeTime_ ?
        item.modificationByMeTime || item.modificationTime :
        item.modificationTime;

    div.textContent = this.formatter_!.formatModDate(modTime);
  }

  private updateGroupHeading_() {
    const fileListModel = this.dataModel as FileListModel;
    if (fileListModel &&
        fileListModel.groupByField === GROUP_BY_FIELD_MODIFICATION_TIME) {
      // TODO(crbug.com/1353650): find a way to update heading instead of redraw
      this.redraw();
    }
  }

  /**
   * Updates the file metadata in the table item.
   *
   * @param item Table item.
   * @param entry File entry.
   */
  updateFileMetadata(item: HTMLElement, entry: Entry|FilesAppEntry) {
    this.updateDate_(item.querySelector<HTMLElement>('.date')!, entry);
    this.updateSize_(item.querySelector<HTMLElement>('.size')!, entry);
  }

  /**
   * Updates list items 'in place' on metadata change.
   * @param type Type of metadata change.
   * @param entries Entries to update.
   */
  updateListItemsMetadata(type: string, entries: Array<Entry|FilesAppEntry>) {
    const urls = entriesToURLs(entries);
    assert(this.dataModel);
    const dataModel = this.dataModel;
    const forEachCell =
        (selector: string,
         callback: (
             cell: HTMLElement, entry: Entry|FilesAppEntry,
             item: ListItem|null) => void) => {
          const cells = this.querySelectorAll<HTMLElement>(selector);
          for (let i = 0; i < cells.length; i++) {
            const cell = cells[i]!;
            const listItem = this.list.getListItemAncestor(cell);
            const index = listItem?.listIndex ?? 0;
            const entry = dataModel.item(index);
            if (entry && urls.indexOf(entry.toURL()) !== -1) {
              callback.call(this, cell, entry, listItem);
            }
          }
        };
    if (type === 'filesystem') {
      forEachCell('.table-row-cell .date', (item, entry) => {
        this.updateDate_(item, entry);
      });
      forEachCell('.table-row-cell > .size', (item, entry) => {
        this.updateSize_(item, entry);
      });
      this.updateGroupHeading_();
    } else if (type === 'external') {
      // The cell name does not matter as the entire list item is needed.
      forEachCell(
          '.table-row-cell .date',
          (_item: HTMLElement, entry: Entry|FilesAppEntry,
           listItem: ListItem|null) => {
            updateListItemExternalProps(
                listItem!, entry,
                this.metadataModel_!.getCache(
                    [entry],
                    [
                      'availableOffline',
                      'customIconUrl',
                      'shared',
                      'isMachineRoot',
                      'isExternalMedia',
                      'hosted',
                      'pinned',
                      'syncStatus',
                      'progress',
                      'syncCompletedTime',
                      'shortcut',
                      'canPin',
                      'isDlpRestricted',
                    ])[0]!,
                isTeamDriveRoot(entry));
            listItem!.toggleAttribute(
                'disabled',
                isDlpBlocked(
                    entry, this.metadataModel_!, this.volumeManager_!));
          });
    }
  }

  /**
   * Renders table row.
   * @param baseRenderFunction Base renderer.
   * @param entry Corresponding entry.
   * @return Created element.
   */
  private renderTableRow_(
      baseRenderFunction: (entry: Entry, table: Table) => ListItem,
      entry: Entry): ListItem {
    const item = baseRenderFunction(entry, this);
    const nameId = item.id + '-entry-name';
    const sizeId = item.id + '-size';
    const typeId = item.id + '-type';
    const dateId = item.id + '-date';
    const dlpId = item.id + '-dlp-managed-icon';
    const encryptedId = item.id + '-encrypted-icon';
    decorateListItem(item, entry, this.metadataModel_!, this.volumeManager_!);
    item.setAttribute('file-name', entry.name);
    item.querySelector('.detail-name')!.setAttribute('id', nameId);
    item.querySelector('.size')!.setAttribute('id', sizeId);
    item.querySelector('.type')!.setAttribute('id', typeId);
    item.querySelector('.date')!.setAttribute('id', dateId);
    const dlpManagedIcon = item.querySelector('.dlp-managed-icon');
    if (dlpManagedIcon) {
      dlpManagedIcon.setAttribute('id', dlpId);
      this.ownerDocument.querySelector('files-tooltip')!.addTargets(
          item.querySelectorAll('.dlp-managed-icon'));
    }
    const encryptedIcon = item.querySelector('.encrypted-icon');
    if (encryptedIcon) {
      encryptedIcon.setAttribute('id', encryptedId);
    }

    item.setAttribute(
        'aria-labelledby',
        `${nameId} column-size ${sizeId} column-type ${
            typeId} column-modificationTime ${dateId} ${encryptedId}`);
    return item;
  }

  /**
   * Renders the file thumbnail in the detail table.
   * @param entry The Entry object to render.
   * @param parent The parent DOM element.
   * @return Created element.
   */
  private renderThumbnail_(entry: Entry, parent: HTMLDivElement):
      HTMLDivElement {
    const box = this.ownerDocument.createElement('div');
    box.className = 'detail-thumbnail';

    // Set thumbnail if it's already in cache.
    const thumbnailData = this.listThumbnailLoader_ ?
        this.listThumbnailLoader_.getThumbnailFromCache(entry) :
        null;
    if (thumbnailData && thumbnailData.dataUrl) {
      this.setThumbnailImage_(box, thumbnailData.dataUrl);
      parent.classList.add('has-thumbnail');
    }

    return box;
  }

  /**
   * Sets thumbnail image to the box.
   * @param box Detail thumbnail div element.
   * @param dataUrl Data url of thumbnail.
   */
  private setThumbnailImage_(box: HTMLDivElement, dataUrl: string) {
    const thumbnail = box.ownerDocument.createElement('div');
    thumbnail.classList.add('thumbnail');
    thumbnail.style.backgroundImage = 'url(' + dataUrl + ')';
    const oldThumbnails = box.querySelectorAll('.thumbnail');

    for (let i = 0; i < oldThumbnails.length; i++) {
      box.removeChild(oldThumbnails[i]!);
    }

    box.appendChild(thumbnail);
  }

  /**
   * Clears thumbnail image from the box.
   * @param box Detail thumbnail div element.
   */
  private clearThumbnailImage_(box: HTMLDivElement) {
    const oldThumbnails = box.querySelectorAll('.thumbnail');

    for (let i = 0; i < oldThumbnails.length; i++) {
      box.removeChild(oldThumbnails[i]!);
    }
  }

  /**
   * Renders the selection checkmark in the detail table.
   * @return Created element.
   */
  private renderCheckmark_(): HTMLDivElement {
    const checkmark = this.ownerDocument.createElement('div');
    checkmark.className = 'detail-checkmark';
    return checkmark;
  }

  /**
   * Renders the DLP managed icon in the detail table.
   * @param isDlpRestricted Whether the icon should be shown.
   * @return Created element.
   */
  private renderDlpManagedIcon_(isDlpRestricted: boolean): HTMLDivElement {
    const icon = this.ownerDocument.createElement('div');
    icon.className = 'dlp-managed-icon';
    icon.toggleAttribute('has-tooltip');
    icon.dataset['tooltipLinkHref'] = str('DLP_HELP_URL');
    icon.dataset['tooltipLinkAriaLabel'] = str('DLP_MANAGED_ICON_TOOLTIP_DESC');
    icon.dataset['tooltipLinkText'] = str('DLP_MANAGED_ICON_TOOLTIP_LINK');
    icon.role = 'link';
    icon.setAttribute('aria-label', str('DLP_MANAGED_ICON_TOOLTIP'));
    icon.toggleAttribute('show-card-tooltip');
    icon.classList.toggle('is-dlp-restricted', isDlpRestricted);
    icon.toggleAttribute('aria-hidden', isDlpRestricted);
    return icon;
  }

  /**
   * Renders the encrypted icon in the detail table, used to mark Google Drive
   * CSE files.
   * @return Created element.
   */
  private renderEncryptedIcon_(): HTMLDivElement {
    const icon = this.ownerDocument.createElement('div');
    icon.className = 'encrypted-icon';
    icon.role = 'image';
    icon.setAttribute('aria-label', str('ENCRYPTED_ICON_TOOLTIP'));
    document.querySelector('files-tooltip')?.addTarget(icon);
    return icon;
  }

  /**
   * Redraws the UI. Skips multiple consecutive calls.
   */
  relayout() {
    this.relayoutRateLimiter_!.run();
  }

  /**
   * Redraws the UI immediately.
   */
  private relayoutImmediately_() {
    if (this.clientWidth > 0) {
      this.normalizeColumns();
    }
    this.redraw();
    dispatchSimpleEvent(this.list, 'relayout');
  }
}