Reference Source

coral-spectrum/coral-component-masonry/src/scripts/MasonryColumnLayout.js

/**
 * Copyright 2019 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import MasonryLayout from './MasonryLayout';
import {setTransition, setTransform, csspx, getPositiveNumberProperty} from './MasonryLayoutUtil';
import {Keys} from '../../../coral-utils';

/**
 Base class for column-based masonry layouts.

 @class Coral.Masonry.ColumnLayout
 @classdesc A Masonry Column layout
 @extends {MasonryLayout}
 */
class MasonryColumnLayout extends MasonryLayout {
  /**
   Takes a {Masonry} instance as argument.

   @param {Masonry} masonry
   */
  constructor(masonry) {
    super(masonry);

    this._columns = [];

    const up = this._moveFocusVertically.bind(this, true);
    const down = this._moveFocusVertically.bind(this, false);
    const left = this._moveFocusHorizontally.bind(this, true);
    const right = this._moveFocusHorizontally.bind(this, false);
    const home = this._moveFocusHomeEnd.bind(this, true);
    const end = this._moveFocusHomeEnd.bind(this, false);

    const keys = this._keys = new Keys(masonry, {
      context: this
    });
    keys.on('up', up).on('k', up);
    keys.on('down', down).on('j', down);
    keys.on('left', left).on('h', left);
    keys.on('right', right).on('l', right);
    keys.on('home', home);
    keys.on('end', end);
  }

  /**
   Hook to remove layout specific style and data from the item.

   @param item
   @private
   */
  // eslint-disable-next-line no-unused-vars
  _resetItem(item) {
    // To override
  }

  /**
   Initialize layout variables.

   @private
   */
  _init(items) {
    const firstItem = items[0];
    const masonry = this._masonry;
    this._columnWidth = getPositiveNumberProperty(masonry, 'columnWidth', 'columnwidth', 200);

    this._zeroOffsetLeft = -csspx(firstItem, 'marginLeft');
    // with padding
    this._masonryInnerWidth = masonry.clientWidth;

    const spacing = this._masonry.spacing;
    if (typeof spacing === 'number') {
      this._horSpacing = spacing;
      this._verSpacing = spacing;
      this._offsetLeft = spacing + this._zeroOffsetLeft;
      this._offsetTop = spacing - csspx(firstItem, 'marginTop');
      this._verPadding = 2 * spacing;
      this._masonryAvailableWidth = masonry.clientWidth - spacing;
    } else {
      this._horSpacing = csspx(firstItem, 'marginLeft') + csspx(firstItem, 'marginRight');
      this._verSpacing = csspx(firstItem, 'marginTop') + csspx(firstItem, 'marginBottom');
      this._offsetLeft = csspx(masonry, 'paddingLeft');
      this._offsetTop = csspx(masonry, 'paddingTop');
      this._verPadding = this._offsetTop + this._verSpacing + csspx(masonry, 'paddingBottom');
      this._masonryAvailableWidth = masonry.clientWidth - this._offsetLeft - csspx(masonry, 'paddingRight');
    }

    // Initialize column objects
    const columnCount = Math.max(1, Math.floor(this._masonryAvailableWidth / (this._columnWidth + this._horSpacing)));
    this._columns.length = columnCount;
    for (let ci = 0 ; ci < columnCount ; ci++) {
      this._columns[ci] = {
        height: this._offsetTop,
        items: []
      };
    }

    // Prepare layout data
    for (let ii = 0 ; ii < items.length ; ii++) {
      const item = items[ii];

      let layoutData = item._layoutData;
      if (!layoutData) {
        item._layoutData = layoutData = {};
      }

      // Read colspan
      layoutData.colspan = Math.min(getPositiveNumberProperty(item, 'colspan', 'colspan', 1), this._columns.length);
    }
  }

  /**
   Updates the width of all items.

   @param items
   @private
   */
  _writeStyles(items) {
    for (let i = 0 ; i < items.length ; i++) {
      const item = items[i];
      const layoutData = item._layoutData;

      // Update width
      const itemWidth = Math.round(this._getItemWidth(layoutData.colspan));
      if (layoutData.width !== itemWidth) {
        item.style.width = `${itemWidth}px`;
        layoutData.width = itemWidth;
      }
      this._writeItemStyle(item);
    }
  }

  /**
   @param colspan column span of the item
   @return the width of the item for the given colspan
   @private
   */
  // eslint-disable-next-line no-unused-vars
  _getItemWidth(colspan) {
    // To override
  }

  /**
   Hook to execute layout specific item preparation.

   @param item
   @private
   */
  // eslint-disable-next-line no-unused-vars
  _writeItemStyle(item) {
    // To override
  }

  /**
   Reads the dimension of all items.

   @param items
   @private
   */
  _readStyles(items) {
    // Record size of items in a separate loop to avoid unneccessary reflows
    for (let i = 0 ; i < items.length ; i++) {
      const item = items[i];
      const layoutData = item._layoutData;
      layoutData.height = Math.round(item.getBoundingClientRect().height);
      layoutData.ignored = layoutData.detached || !item.offsetParent;
    }
  }

  /**
   Update the position of all items.

   @param items
   @private
   */
  _positionItems(items) {
    let j;

    for (let i = 0 ; i < items.length ; i++) {
      const item = items[i];
      const layoutData = item._layoutData;
      // Skip ignored items
      if (layoutData.ignored) {
        continue;
      }

      // Search for column with the least height
      const maxLength = this._columns.length - (layoutData.colspan - 1);
      let minColumnIndex = -1;
      let minColumnHeight;
      for (j = 0 ; j < maxLength ; j++) {
        // can be negative if set spacing < item css margin
        let columnHeight = this._offsetTop;
        for (let y = 0 ; y < layoutData.colspan ; y++) {
          columnHeight = Math.max(columnHeight, this._columns[j + y].height);
        }
        if (minColumnIndex === -1 || columnHeight < minColumnHeight) {
          minColumnIndex = j;
          minColumnHeight = columnHeight;
        }
      }

      const top = minColumnHeight;
      const left = Math.round(this._getItemLeft(minColumnIndex));

      // Check if position has changed
      if (layoutData.left !== left || layoutData.top !== top) {
        layoutData.columnIndex = minColumnIndex;
        layoutData.itemIndex = this._columns[minColumnIndex].items.length;
        layoutData.left = left;
        layoutData.top = top;

        setTransform(item, `translate(${left}px, ${top}px)`);
      }

      // Remember new column height to position all other items
      const newColumnHeight = top + layoutData.height + this._verSpacing;
      for (j = 0 ; j < layoutData.colspan ; j++) {
        const column = this._columns[minColumnIndex + j];
        column.height = newColumnHeight;
        column.items.push(item);
      }
    }
  }

  /**
   @param columnIndex
   @return the left position for the given column index
   @private
   */
  // eslint-disable-next-line no-unused-vars
  _getItemLeft(columnIndex) {
    // To override
  }

  /**
   @returns {number} the height of the content (independent of the current gird container height)
   @private
   */
  _getContentHeight() {
    return this._columns.reduce((height, column) => Math.max(height, column.height), 0) - this._offsetTop;
  }

  /**
   Hook which is called after the positioning is done.

   @param contentHeight
   @private
   */
  // eslint-disable-next-line no-unused-vars
  _postLayout(contentHeight) {
    // To override
  }

  /**
   Moves the focus vertically.

   @private
   */
  _moveFocusVertically(up, event) {
    const currentLayoutData = event.target._layoutData;
    if (!currentLayoutData) {
      return;
    }

    // Choose item above or below
    const nextItemIndex = currentLayoutData.itemIndex + (up ? -1 : 1);
    let nextItem = this._columns[currentLayoutData.columnIndex].items[nextItemIndex];

    if (nextItem) {
      nextItem.focus();
      // prevent scrolling at the same time
      event.preventDefault();
    } else {
      // in case there is no item in the same column, we should move to first item in next column for down
      // and last item of previous column for up key
      let columnIndex = currentLayoutData.columnIndex;
      if (up) {
        if (columnIndex > 0) {
          // move to last item of previous column
          let prevColumn = this._columns[columnIndex - 1];
          if (prevColumn) {
            nextItem = prevColumn.items[prevColumn.items.length - 1]; // last item of previous column
          }
        }
      } else {
        // down key is pressed, go to first item of next column if exists
        let columnCount = this._columns.length;
        let nextColumnIndex = columnIndex + currentLayoutData.colspan;
        if (nextColumnIndex < columnCount) {
          nextItem = this._columns[nextColumnIndex].items[0]; // first item of next column
        }
      }
      if (nextItem) {
        nextItem.focus();
        event.preventDefault(); // prevent scrolling at the same time
      }
    }
  }

  /**
   Moves the focus horizontally.

   @private
   */
  _moveFocusHorizontally(left, event) {
    const currentLayoutData = event.target._layoutData;
    if (!currentLayoutData) {
      return;
    }

    let nextItem;
    let items = this._masonry.items.getAll();
    let collectionItemIndex = items.indexOf(event.target);

    if (left) {
      if (collectionItemIndex > 0) {
        nextItem = items[collectionItemIndex - 1];
      }
    } else if (collectionItemIndex < items.length - 1) {
      nextItem = items[collectionItemIndex + 1];
    }

    if (nextItem) {
      nextItem.focus();
      event.preventDefault(); // prevent scrolling at the same time
    }
  }

  /**
   Moves the focus to first or last item based on the visual order.

   @private
   */
  _moveFocusHomeEnd(home, event) {
    const currentLayoutData = event.target._layoutData;
    if (!currentLayoutData) {
      return;
    }

    let nextItem;
    const columns = this._columns;

    // when home is pressed, we take the first item of the first column
    if (home) {
      nextItem = columns[0] && columns[0].items[0];
    } else {
      // when end is pressed, we take the last item of the last column; since some columns are empty, we need to
      // iterate backwards to find the first column that has items
      for (let i = columns.length - 1 ; i > -1 ; i--) {
        // since we found a column with items, we take the last item as the next one
        if (columns[i].items.length > 0) {
          nextItem = columns[i].items[columns[i].items.length - 1];
          break;
        }
      }
    }

    if (nextItem) {
      nextItem.focus();
      // we prevent the scrolling
      event.preventDefault();
    }
  }

  /** @inheritdoc */
  layout(secondTry) {
    const masonry = this._masonry;

    const items = masonry.items.getAll();
    if (items.length > 0) {
      // For best possible performance none of these function calls must both read and write attributes in a loop to
      // avoid unnecessary reflows.
      this._init(items);
      this._writeStyles(items);
      this._readStyles(items);
      this._positionItems(items);
    } else {
      this._columns.length = 0;
    }

    // Update the height of the masonry (otherwise it has a height of 0px due to the absolutely positioned items)
    const contentHeight = this._getContentHeight();
    masonry.style.height = `${contentHeight - this._verSpacing + this._verPadding}px`;

    // Check if the masonry has changed its width due to the changed height (can happen because of appearing/disappearing scrollbars)
    if (!secondTry && this._masonryInnerWidth !== masonry.clientWidth) {
      this.layout(true);
    } else {
      // Post layout hook for sub classes
      this._postLayout(contentHeight);
    }
  }

  /** @inheritdoc */
  destroy() {
    this._keys.destroy();

    const items = this._masonry.items.getAll();
    for (let i = 0 ; i < items.length ; i++) {
      const item = items[i];
      item._layoutData = undefined;
      setTransform(item, '');
      this._resetItem(item);
    }
  }

  /** @inheritdoc */
  detach(item) {
    item._layoutData.detached = true;
  }

  /** @inheritdoc */
  reattach(item) {
    const layoutData = item._layoutData;
    layoutData.detached = false;

    const rect = item.getBoundingClientRect();
    // Disable transition while repositioning
    setTransition(item, 'none');
    item.style.left = '';
    item.style.top = '';
    setTransform(item, '');

    const nullRect = item.getBoundingClientRect();
    layoutData.left = rect.left - nullRect.left;
    layoutData.top = rect.top - nullRect.top;
    setTransform(item, `translate(${layoutData.left}px, ${layoutData.top}px)`);
    // Enforce position
    item.getBoundingClientRect();
    // Enable transition again
    setTransition(item, '');
  }

  /** @inheritdoc */
  itemAt(x, y) {
    // TODO it would be more efficient to pick first the right column
    const items = this._masonry.items.getAll();

    for (let i = 0 ; i < items.length ; i++) {
      const item = items[i];
      const layoutData = item._layoutData;

      if (layoutData && !layoutData.ignored && (
        layoutData.left <= x && layoutData.left + layoutData.width >= x &&
        layoutData.top <= y && layoutData.top + layoutData.height >= y)) {
        return item;
      }
    }

    return null;
  }
}

export default MasonryColumnLayout;