chromium/ui/file_manager/file_manager/common/js/array_data_model.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.

/**
 * @fileoverview This is a data model representin
 */

import {assert} from 'chrome://resources/js/assert.js';

import {type CustomEventMap, FilesEventTarget} from './files_event_target.js';

export type SpliceEvent = CustomEvent<{
  removed: any[],
  added: any[],
  index?: number,
}>;

export type ChangeEvent = CustomEvent<{
  index: number,
}>;

export type PermutationEvent = CustomEvent<{
  permutation: number[],
  newLength: number,
}>;

interface ArrayDataModelEventMap extends CustomEventMap {
  'splice': SpliceEvent;
  'change': ChangeEvent;
  'permuted': PermutationEvent;
}

type CompareFunction<T> = (a: T, b: T) => number;

interface SortStatus {
  field: string|null;
  direction: string|null;
}

/**
 * Default compare function.
 */
function defaultValuesCompareFunction(a: number, b: number) {
  // We could insert i18n comparisons here.
  if (a < b) {
    return -1;
  }
  if (a > b) {
    return 1;
  }
  return 0;
}

/**
 * A data model that wraps a simple array and supports sorting by storing
 * initial indexes of elements for each position in sorted array.
 */
export class ArrayDataModel<T = any> extends
    FilesEventTarget<ArrayDataModelEventMap> {
  protected indexes_: number[] = [];
  protected compareFunctions_: Record<string, CompareFunction<T>> = {};
  private sortStatus_: SortStatus = {field: null, direction: null};

  /**
   * @param array The underlying array.
   */
  constructor(protected array_: T[]) {
    super();

    for (let i = 0; i < this.array_.length; i++) {
      this.indexes_.push(i);
    }
  }

  /**
   * The length of the data model.
   */
  get length() {
    return this.array_.length;
  }

  /**
   * Returns the item at the given index.
   * This implementation returns the item at the given index in the sorted
   * array.
   * @param index The index of the element to get.
   * @return The element at the given index.
   */
  item(index: number): T|undefined {
    if (index >= 0 && index < this.length) {
      return this.array_[this.indexes_[index]!];
    }
    return undefined;
  }

  /**
   * Returns compare function set for given field.
   * @param field The field to get compare function for.
   * @return Compare function set for given field.
   */
  compareFunction(field: string): CompareFunction<T>|undefined {
    return this.compareFunctions_[field];
  }

  /**
   * Sets compare function for given field.
   * @param field The field to set compare function.
   * @param compareFunction Compare function to set for given field.
   */
  setCompareFunction(field: string, compareFunction: CompareFunction<T>) {
    if (!this.compareFunctions_) {
      this.compareFunctions_ = {};
    }
    this.compareFunctions_[field] = compareFunction;
  }

  /**
   * Returns true if the field has a compare function.
   * @param field The field to check.
   * @return True if the field is sortable.
   */
  isSortable(field: string): boolean {
    return this.compareFunctions_ && field in this.compareFunctions_;
  }

  /**
   * Returns current sort status.
   * @return Current sort status.
   */
  get sortStatus(): {field: string|null, direction: string|null} {
    if (this.sortStatus_) {
      return this.createSortStatus(
          this.sortStatus_.field, this.sortStatus_.direction);
    } else {
      return this.createSortStatus(null, null);
    }
  }

  /**
   * Returns the first matching item.
   * @param item The item to find.
   * @param fromIndex If provided, then the searching start at the fromIndex.
   * @return The index of the first found element or -1 if not found.
   */
  indexOf(item: T, fromIndex?: number): number {
    for (let i = fromIndex || 0; i < this.indexes_.length; i++) {
      if (item === this.item(i)) {
        return i;
      }
    }
    return -1;
  }

  /**
   * Returns an array of elements in a selected range.
   * @param from The starting index of the selected range.
   * @param to The ending index of selected range.
   * @return An array of elements in the selected range.
   */
  slice(from?: number, to?: number): T[] {
    const arr = this.array_;
    return this.indexes_.slice(from, to).map((index) => {
      return arr[index]!;
    });
  }

  /**
   * This removes and adds items to the model.
   * This dispatches a splice event.
   * This implementation runs sort after splice and creates permutation for
   * the whole change.
   * @param index The index of the item to update.
   * @param deleteCount The number of items to remove.
   * @param itemsToAdd The items to add.
   * @return An array with the removed items.
   */
  splice(index: number, deleteCount: number, ...itemsToAdd: T[]): T[] {
    const addCount = itemsToAdd.length;
    const newIndexes = [];
    const deletePermutation = [];
    const deletedItems: T[] = [];
    const newArray = [];
    index = Math.min(index, this.indexes_.length);
    deleteCount = Math.min(deleteCount, this.indexes_.length - index);
    // Copy items before the insertion point.
    let i;
    for (i = 0; i < index; i++) {
      newIndexes.push(newArray.length);
      deletePermutation.push(i);
      newArray.push(this.array_[this.indexes_[i]!]);
    }
    // Delete items.
    for (; i < index + deleteCount; i++) {
      deletePermutation.push(-1);
      deletedItems.push(this.array_[this.indexes_[i]!]!);
    }
    // Insert new items instead deleted ones.
    for (let j = 0; j < addCount; j++) {
      newIndexes.push(newArray.length);
      newArray.push(arguments[j + 2]);
    }
    // Copy items after the insertion point.
    for (; i < this.indexes_.length; i++) {
      newIndexes.push(newArray.length);
      deletePermutation.push(i - deleteCount + addCount);
      newArray.push(this.array_[this.indexes_[i]!]);
    }

    this.indexes_ = newIndexes;

    this.array_ = newArray;

    // TODO(arv): Maybe unify splice and change events?
    const spliceEventDetail: SpliceEvent['detail'] = {
      removed: deletedItems,
      added: itemsToAdd,
    };

    const status = this.sortStatus;
    // if sortStatus.field is null, this restores original order.
    const sortPermutation =
        this.doSort_(this.sortStatus.field, this.sortStatus.direction);
    if (sortPermutation) {
      const splicePermutation = deletePermutation.map((element) => {
        return element !== -1 ? sortPermutation[element]! : -1;
      });
      this.dispatchPermutedEvent_(splicePermutation);
      spliceEventDetail.index = sortPermutation[index];
    } else {
      this.dispatchPermutedEvent_(deletePermutation);
      spliceEventDetail.index = index;
    }

    this.dispatchEvent(new CustomEvent('splice', {detail: spliceEventDetail}));

    // Still need to finish the sorting above (including events), so
    // list will not go to inconsistent state.
    if (status.field) {
      this.delayedSort_(status.field, status.direction);
    }

    return deletedItems;
  }

  /**
   * Appends items to the end of the model.
   *
   * This dispatches a splice event.
   *
   * @param itemsToAppend The items to append.
   * @return The new length of the model.
   */
  push(...itemsToAppend: T[]): number {
    this.splice(this.length, 0, ...itemsToAppend);
    return this.length;
  }

  /**
   * Updates the existing item with the new item.
   *
   * The existing item and the new item are regarded as the same item and the
   * permutation tracks these indexes.
   *
   * @param oldItem Old item that is contained in the model. If the item is not
   *     found in the model, the method call is just ignored.
   * @param newItem New item.
   */
  replaceItem(oldItem: T, newItem: T) {
    const index = this.indexOf(oldItem);
    if (index < 0) {
      return;
    }
    this.array_[this.indexes_[index]!] = newItem;
    this.updateIndex(index);
  }

  /**
   * Use this to update a given item in the array. This does not remove and
   * reinsert a new item.
   * This dispatches a change event.
   * This runs sort after updating.
   * @param index The index of the item to update.
   */
  updateIndex(index: number) {
    this.updateIndexes([index]);
  }

  /**
   * Notifies of update of the items in the array. This does not remove and
   * reinsert new items.
   * This dispatches one or more change events.
   * This runs sort after updating.
   * @param indexes The index list of items to update.
   */
  updateIndexes(indexes: number[]) {
    indexes.forEach(index => {
      assert(index >= 0 && index < this.length, 'Invalid index');
    });

    for (const index of indexes) {
      const e = new CustomEvent('change', {detail: {index}});
      this.dispatchEvent(e);
    }

    if (!this.sortStatus.field) {
      return;
    }

    const status = this.sortStatus;
    const sortPermutation =
        this.doSort_(this.sortStatus.field, this.sortStatus.direction);
    if (sortPermutation) {
      this.dispatchPermutedEvent_(sortPermutation);
    }
    // Still need to finish the sorting above (including events), so
    // list will not go to inconsistent state.
    this.delayedSort_(status.field, status.direction);
  }

  /**
   * Creates sort status with given field and direction.
   * @param field Sort field.
   * @param direction Sort direction.
   * @return Created sort status.
   */
  createSortStatus(field: string|null, direction: string|null): SortStatus {
    return {field: field, direction: direction};
  }

  /**
   * Sorts data model according to given field and direction and dispatches
   * sorted event with delay. If no need to delay, use sort() instead.
   * @param field Sort field.
   * @param direction Sort direction.
   */
  private delayedSort_(field: string|null, direction: string|null) {
    setTimeout(() => {
      // If the sort status has been changed, sorting has already done
      // on the change event.
      if (field === this.sortStatus.field &&
          direction === this.sortStatus.direction) {
        this.sort(field, direction);
      }
    }, 0);
  }

  /**
   * Sorts data model according to given field and direction and dispatches
   * sorted event.
   * @param field Sort field.
   * @param direction Sort direction.
   */
  sort(field: string|null, direction: string|null) {
    const sortPermutation = this.doSort_(field, direction);
    if (sortPermutation) {
      this.dispatchPermutedEvent_(sortPermutation);
    }
    this.dispatchSortEvent_();
  }

  /**
   * Sorts data model according to given field and direction.
   * @param field Sort field.
   * @param direction Sort direction.
   */
  private doSort_(field: string|null, direction: string|null) {
    const compareFunction = this.sortFunction_(field, direction);
    const positions: number[] = [];
    for (let i = 0; i < this.length; i++) {
      positions[this.indexes_[i]!] = i;
    }
    const sorted = this.indexes_.every((element, index, array) => {
      return index === 0 || compareFunction(element, array[index - 1]!) >= 0;
    });
    if (!sorted) {
      this.indexes_.sort(compareFunction);
    }
    this.sortStatus_ = this.createSortStatus(field, direction);
    const sortPermutation: number[] = [];
    let changed = false;
    for (let i = 0; i < this.length; i++) {
      if (positions[this.indexes_[i]!] !== i) {
        changed = true;
      }
      sortPermutation[positions[this.indexes_[i]!]!] = i;
    }
    if (changed) {
      return sortPermutation;
    }
    return null;
  }

  private dispatchSortEvent_() {
    const e = new Event('sorted');
    this.dispatchEvent(e);
  }

  protected dispatchPermutedEvent_(permutation: number[]) {
    const e = new CustomEvent(
        'permuted', {detail: {permutation, newLength: this.length}});
    this.dispatchEvent(e);
  }

  /**
   * Creates compare function for the field.
   * Returns the function set as sortFunction for given field or default compare
   * function
   * @param field Sort field.
   * @return Compare function.
   */
  private createCompareFunction_(field: string): CompareFunction<T> {
    const compareFunction =
        this.compareFunctions_ ? this.compareFunctions_[field] : null;
    if (compareFunction) {
      return compareFunction;
    } else {
      return function(a: any, b: any) {
        return defaultValuesCompareFunction(a[field], b[field]);
      };
    }
  }

  /**
   * Creates compare function for given field and direction.
   * @param field Sort field.
   * @param direction Sort direction.
   */
  private sortFunction_(field: string|null, direction: string|null) {
    let compareFunction: CompareFunction<T>|null = null;
    if (field !== null) {
      compareFunction = this.createCompareFunction_(field);
    }
    const dirMultiplier = direction === 'desc' ? -1 : 1;

    return (index1: number, index2: number) => {
      const item1 = this.array_[index1]!;
      const item2 = this.array_[index2]!;

      let compareResult = 0;
      if (typeof (compareFunction) === 'function') {
        compareResult = compareFunction(item1, item2);
      }
      if (compareResult !== 0) {
        return dirMultiplier * compareResult;
      }
      return dirMultiplier * defaultValuesCompareFunction(index1, index2);
    };
  }
}