chromium/ui/file_manager/file_manager/foreground/js/ui/list_selection_controller.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 type {ListSelectionModel} from './list_selection_model.js';
import type {ListSingleSelectionModel} from './list_single_selection_model.js';

/**
 * The selection controller that is to be used with lists. This is implemented
 * for vertical lists but changing the behavior for horizontal lists or icon
 * views is a matter of overriding `getIndexBefore()`, `getIndexAfter()`,
 * `getIndexAbove()` as well as `getIndexBelow()`.
 */
export class ListSelectionController {
  /**
   * @param selectionModel The selection model to interact with.
   */
  constructor(private selectionModel_: ListSelectionModel|
              ListSingleSelectionModel) {}


  /**
   * The selection model we are interacting with.
   */
  get selectionModel(): ListSelectionModel|ListSingleSelectionModel {
    return this.selectionModel_;
  }

  /**
   * Returns the index below (y axis) the given element.
   * @param index The index to get the index below.
   * @return The index below or -1 if not found.
   */
  getIndexBelow(index: number): number {
    if (index === this.getLastIndex()) {
      return -1;
    }
    return index + 1;
  }

  /**
   * Returns the index above (y axis) the given element.
   * @param index The index to get the index above.
   * @return The index below or -1 if not found.
   */
  getIndexAbove(index: number): number {
    return index - 1;
  }

  /**
   * Returns the index before (x axis) the given element. This returns -1
   * by default but override this for icon view and horizontal selection
   * models.
   *
   * @param _index The index to get the index before.
   */
  getIndexBefore(_index: number): number {
    return -1;
  }

  /**
   * Returns the index after (x axis) the given element. This returns -1
   * by default but override this for icon view and horizontal selection
   * models.
   *
   * @param index The index to get the index after.
   */
  getIndexAfter(_index: number): number {
    return -1;
  }

  /**
   * Returns the next list index. This is the next logical and should not
   * depend on any kind of layout of the list.
   * @param index The index to get the next index for.
   * @return The next index or -1 if not found.
   */
  getNextIndex(index: number): number {
    if (index === this.getLastIndex()) {
      return -1;
    }
    return index + 1;
  }

  /**
   * Returns the previous list index. This is the previous logical and should
   * not depend on any kind of layout of the list.
   * @param index The index to get the previous index for.
   * @return The previous index or -1 if not found.
   */
  getPreviousIndex(index: number): number {
    return index - 1;
  }

  /**
   * @return The first index.
   */
  getFirstIndex(): number {
    return 0;
  }

  /**
   * @return The last index.
   */
  getLastIndex(): number {
    return this.selectionModel.length - 1;
  }

  /**
   * Called by the view when the user does a mousedown or mouseup on the
   * list.
   * @param e The browser mouse event.
   * @param index The index that was under the mouse pointer, -1 if none.
   */
  handlePointerDownUp(e: MouseEvent, index: number) {
    const sm = this.selectionModel;
    const anchorIndex = sm.anchorIndex;
    const isDown = (e.type === 'mousedown');

    sm.beginChange();

    if (index === -1) {
      // On CrOS we always clear the selection if the user clicks a blank area.
      sm.leadIndex = sm.anchorIndex = -1;
      sm.unselectAll();
    } else {
      if (sm.multiple && (e.ctrlKey && !e.shiftKey)) {
        // Selection is handled at mouseUp on windows/linux, mouseDown on mac.
        if (!isDown) {
          // Toggle the current one and make it anchor index.
          sm.setIndexSelected(index, !sm.getIndexSelected(index));
          sm.leadIndex = index;
          sm.anchorIndex = index;
        }
      } else if (e.shiftKey && anchorIndex !== -1 && anchorIndex !== index) {
        // Shift is done in mousedown.
        if (isDown) {
          sm.unselectAll();
          sm.leadIndex = index;
          if (sm.multiple) {
            sm.selectRange(anchorIndex, index);
          } else {
            sm.setIndexSelected(index, true);
          }
        }
      } else {
        // Right click for a context menu needs to not clear the selection.
        const isRightClick = e.button === 2;

        // If the index is selected this is handled in mouseup.
        const indexSelected = sm.getIndexSelected(index);
        if ((indexSelected && !isDown || !indexSelected && isDown) &&
            !(indexSelected && isRightClick)) {
          sm.selectedIndex = index;
        }
      }
    }

    sm.endChange();
  }

  /**
   * Called by the view when it receives either a touchstart, touchmove,
   * touchend, or touchcancel event.
   * Sub-classes may override this function to handle touch events separately
   * from mouse events, instead of waiting for emulated mouse events sent
   * after the touch events.
   * @param _e The event.
   * @param _index The index that was under the touched point, -1 if none.
   */
  handleTouchEvents(_e: Event, _index: number) {
    // Do nothing.
  }

  /**
   * Called by the view when it receives a keydown event.
   * @param e The keydown event.
   */
  handleKeyDown(e: KeyboardEvent) {
    const target = e.target as HTMLElement;
    const tagName = target.tagName;
    // If focus is in an input field of some kind, only handle navigation keys
    // that aren't likely to conflict with input interaction (e.g., text
    // editing, or changing the value of a checkbox or select).
    if (tagName === 'INPUT') {
      const inputType = (target as HTMLInputElement).type;
      // Just protect space (for toggling) for checkbox and radio.
      if (inputType === 'checkbox' || inputType === 'radio') {
        if (e.key === ' ') {
          return;
        }
        // Protect all but the most basic navigation commands in anything
        // else.
      } else if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') {
        return;
      }
    }
    // Similarly, don't interfere with select element handling.
    if (tagName === 'SELECT') {
      return;
    }

    const sm = this.selectionModel;
    let newIndex = -1;
    const leadIndex = sm.leadIndex;
    let prevent = true;

    // Ctrl/Meta+A
    if (sm.multiple && e.keyCode === 65 && e.ctrlKey) {
      sm.selectAll();
      e.preventDefault();
      return;
    }

    if (e.key === ' ') {
      if (leadIndex !== -1) {
        const selected = sm.getIndexSelected(leadIndex);
        if (e.ctrlKey || !selected) {
          sm.setIndexSelected(leadIndex, !selected || !sm.multiple);
          return;
        }
      }
    }

    switch (e.key) {
      case 'Home':
        newIndex = this.getFirstIndex();
        break;
      case 'End':
        newIndex = this.getLastIndex();
        break;
      case 'ArrowUp':
        newIndex = leadIndex === -1 ? this.getLastIndex() :
                                      this.getIndexAbove(leadIndex);
        break;
      case 'ArrowDown':
        newIndex = leadIndex === -1 ? this.getFirstIndex() :
                                      this.getIndexBelow(leadIndex);
        break;
      case 'ArrowLeft':
      case 'MediaPreviousTrack':
        newIndex = leadIndex === -1 ? this.getLastIndex() :
                                      this.getIndexBefore(leadIndex);
        break;
      case 'ArrowRight':
      case 'MediaNextTrack':
        newIndex = leadIndex === -1 ? this.getFirstIndex() :
                                      this.getIndexAfter(leadIndex);
        break;
      default:
        prevent = false;
    }

    if (newIndex !== -1) {
      sm.beginChange();

      sm.leadIndex = newIndex;
      if (e.shiftKey) {
        const anchorIndex = sm.anchorIndex;
        if (sm.multiple) {
          sm.unselectAll();
        }
        if (anchorIndex === -1) {
          sm.setIndexSelected(newIndex, true);
          sm.anchorIndex = newIndex;
        } else {
          sm.selectRange(anchorIndex, newIndex);
        }
      } else {
        if (sm.multiple) {
          sm.unselectAll();
        }
        sm.setIndexSelected(newIndex, true);
        sm.anchorIndex = newIndex;
      }

      sm.endChange();

      if (prevent) {
        e.preventDefault();
      }
    }
  }
}