ExamplesPlaygroundReference Source

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

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import MasonryLayout from './MasonryLayout';
  14. import {setTransition, setTransform, csspx, getPositiveNumberProperty} from './MasonryLayoutUtil';
  15. import {Keys} from '../../../coral-utils';
  16.  
  17. /**
  18. Base class for column-based masonry layouts.
  19.  
  20. @class Coral.Masonry.ColumnLayout
  21. @classdesc A Masonry Column layout
  22. @extends {MasonryLayout}
  23. */
  24. class MasonryColumnLayout extends MasonryLayout {
  25. /**
  26. Takes a {Masonry} instance as argument.
  27.  
  28. @param {Masonry} masonry
  29. */
  30. constructor(masonry) {
  31. super(masonry);
  32.  
  33. this._columns = [];
  34.  
  35. const up = this._moveFocusVertically.bind(this, true);
  36. const down = this._moveFocusVertically.bind(this, false);
  37. const left = this._moveFocusHorizontally.bind(this, true);
  38. const right = this._moveFocusHorizontally.bind(this, false);
  39. const home = this._moveFocusHomeEnd.bind(this, true);
  40. const end = this._moveFocusHomeEnd.bind(this, false);
  41.  
  42. const keys = this._keys = new Keys(masonry, {
  43. context: this
  44. });
  45. keys.on('up', up).on('k', up);
  46. keys.on('down', down).on('j', down);
  47. keys.on('left', left).on('h', left);
  48. keys.on('right', right).on('l', right);
  49. keys.on('home', home);
  50. keys.on('end', end);
  51. }
  52.  
  53. /**
  54. Hook to remove layout specific style and data from the item.
  55.  
  56. @param item
  57. @private
  58. */
  59. // eslint-disable-next-line no-unused-vars
  60. _resetItem(item) {
  61. // To override
  62. }
  63.  
  64. /**
  65. Initialize layout variables.
  66.  
  67. @private
  68. */
  69. _init(items) {
  70. const firstItem = items[0];
  71. const masonry = this._masonry;
  72. this._columnWidth = getPositiveNumberProperty(masonry, 'columnWidth', 'columnwidth', 200);
  73.  
  74. this._zeroOffsetLeft = -csspx(firstItem, 'marginLeft');
  75. // with padding
  76. this._masonryInnerWidth = masonry.clientWidth;
  77.  
  78. const spacing = this._masonry.spacing;
  79. if (typeof spacing === 'number') {
  80. this._horSpacing = spacing;
  81. this._verSpacing = spacing;
  82. this._offsetLeft = spacing + this._zeroOffsetLeft;
  83. this._offsetTop = spacing - csspx(firstItem, 'marginTop');
  84. this._verPadding = 2 * spacing;
  85. this._masonryAvailableWidth = masonry.clientWidth - spacing;
  86. } else {
  87. this._horSpacing = csspx(firstItem, 'marginLeft') + csspx(firstItem, 'marginRight');
  88. this._verSpacing = csspx(firstItem, 'marginTop') + csspx(firstItem, 'marginBottom');
  89. this._offsetLeft = csspx(masonry, 'paddingLeft');
  90. this._offsetTop = csspx(masonry, 'paddingTop');
  91. this._verPadding = this._offsetTop + this._verSpacing + csspx(masonry, 'paddingBottom');
  92. this._masonryAvailableWidth = masonry.clientWidth - this._offsetLeft - csspx(masonry, 'paddingRight');
  93. }
  94.  
  95. // Initialize column objects
  96. const columnCount = Math.max(1, Math.floor(this._masonryAvailableWidth / (this._columnWidth + this._horSpacing)));
  97. this._columns.length = columnCount;
  98. for (let ci = 0 ; ci < columnCount ; ci++) {
  99. this._columns[ci] = {
  100. height: this._offsetTop,
  101. items: []
  102. };
  103. }
  104.  
  105. // Prepare layout data
  106. for (let ii = 0 ; ii < items.length ; ii++) {
  107. const item = items[ii];
  108.  
  109. let layoutData = item._layoutData;
  110. if (!layoutData) {
  111. item._layoutData = layoutData = {};
  112. }
  113.  
  114. // Read colspan
  115. layoutData.colspan = Math.min(getPositiveNumberProperty(item, 'colspan', 'colspan', 1), this._columns.length);
  116. }
  117. }
  118.  
  119. /**
  120. Updates the width of all items.
  121.  
  122. @param items
  123. @private
  124. */
  125. _writeStyles(items) {
  126. for (let i = 0 ; i < items.length ; i++) {
  127. const item = items[i];
  128. const layoutData = item._layoutData;
  129.  
  130. // Update width
  131. const itemWidth = Math.round(this._getItemWidth(layoutData.colspan));
  132. if (layoutData.width !== itemWidth) {
  133. item.style.width = `${itemWidth}px`;
  134. layoutData.width = itemWidth;
  135. }
  136. this._writeItemStyle(item);
  137. }
  138. }
  139.  
  140. /**
  141. @param colspan column span of the item
  142. @return the width of the item for the given colspan
  143. @private
  144. */
  145. // eslint-disable-next-line no-unused-vars
  146. _getItemWidth(colspan) {
  147. // To override
  148. }
  149.  
  150. /**
  151. Hook to execute layout specific item preparation.
  152.  
  153. @param item
  154. @private
  155. */
  156. // eslint-disable-next-line no-unused-vars
  157. _writeItemStyle(item) {
  158. // To override
  159. }
  160.  
  161. /**
  162. Reads the dimension of all items.
  163.  
  164. @param items
  165. @private
  166. */
  167. _readStyles(items) {
  168. // Record size of items in a separate loop to avoid unneccessary reflows
  169. for (let i = 0 ; i < items.length ; i++) {
  170. const item = items[i];
  171. const layoutData = item._layoutData;
  172. layoutData.height = Math.round(item.getBoundingClientRect().height);
  173. layoutData.ignored = layoutData.detached || !item.offsetParent;
  174. }
  175. }
  176.  
  177. /**
  178. Update the position of all items.
  179.  
  180. @param items
  181. @private
  182. */
  183. _positionItems(items) {
  184. let j;
  185.  
  186. for (let i = 0 ; i < items.length ; i++) {
  187. const item = items[i];
  188. const layoutData = item._layoutData;
  189. // Skip ignored items
  190. if (layoutData.ignored) {
  191. continue;
  192. }
  193.  
  194. // Search for column with the least height
  195. const maxLength = this._columns.length - (layoutData.colspan - 1);
  196. let minColumnIndex = -1;
  197. let minColumnHeight;
  198. for (j = 0 ; j < maxLength ; j++) {
  199. // can be negative if set spacing < item css margin
  200. let columnHeight = this._offsetTop;
  201. for (let y = 0 ; y < layoutData.colspan ; y++) {
  202. columnHeight = Math.max(columnHeight, this._columns[j + y].height);
  203. }
  204. if (minColumnIndex === -1 || columnHeight < minColumnHeight) {
  205. minColumnIndex = j;
  206. minColumnHeight = columnHeight;
  207. }
  208. }
  209.  
  210. const top = minColumnHeight;
  211. const left = Math.round(this._getItemLeft(minColumnIndex));
  212.  
  213. // Check if position has changed
  214. if (layoutData.left !== left || layoutData.top !== top) {
  215. layoutData.columnIndex = minColumnIndex;
  216. layoutData.itemIndex = this._columns[minColumnIndex].items.length;
  217. layoutData.left = left;
  218. layoutData.top = top;
  219.  
  220. setTransform(item, `translate(${left}px, ${top}px)`);
  221. }
  222.  
  223. // Remember new column height to position all other items
  224. const newColumnHeight = top + layoutData.height + this._verSpacing;
  225. for (j = 0 ; j < layoutData.colspan ; j++) {
  226. const column = this._columns[minColumnIndex + j];
  227. column.height = newColumnHeight;
  228. column.items.push(item);
  229. }
  230. }
  231. }
  232.  
  233. /**
  234. @param columnIndex
  235. @return the left position for the given column index
  236. @private
  237. */
  238. // eslint-disable-next-line no-unused-vars
  239. _getItemLeft(columnIndex) {
  240. // To override
  241. }
  242.  
  243. /**
  244. @returns {number} the height of the content (independent of the current gird container height)
  245. @private
  246. */
  247. _getContentHeight() {
  248. return this._columns.reduce((height, column) => Math.max(height, column.height), 0) - this._offsetTop;
  249. }
  250.  
  251. /**
  252. Hook which is called after the positioning is done.
  253.  
  254. @param contentHeight
  255. @private
  256. */
  257. // eslint-disable-next-line no-unused-vars
  258. _postLayout(contentHeight) {
  259. // To override
  260. }
  261.  
  262. /**
  263. Moves the focus vertically.
  264.  
  265. @private
  266. */
  267. _moveFocusVertically(up, event) {
  268. const currentLayoutData = event.target._layoutData;
  269. if (!currentLayoutData) {
  270. return;
  271. }
  272.  
  273. // Choose item above or below
  274. const nextItemIndex = currentLayoutData.itemIndex + (up ? -1 : 1);
  275. let nextItem = this._columns[currentLayoutData.columnIndex].items[nextItemIndex];
  276.  
  277. if (nextItem) {
  278. nextItem.focus();
  279. // prevent scrolling at the same time
  280. event.preventDefault();
  281. } else {
  282. // in case there is no item in the same column, we should move to first item in next column for down
  283. // and last item of previous column for up key
  284. let columnIndex = currentLayoutData.columnIndex;
  285. if (up) {
  286. if (columnIndex > 0) {
  287. // move to last item of previous column
  288. let prevColumn = this._columns[columnIndex - 1];
  289. if (prevColumn) {
  290. nextItem = prevColumn.items[prevColumn.items.length - 1]; // last item of previous column
  291. }
  292. }
  293. } else {
  294. // down key is pressed, go to first item of next column if exists
  295. let columnCount = this._columns.length;
  296. let nextColumnIndex = columnIndex + currentLayoutData.colspan;
  297. if (nextColumnIndex < columnCount) {
  298. nextItem = this._columns[nextColumnIndex].items[0]; // first item of next column
  299. }
  300. }
  301. if (nextItem) {
  302. nextItem.focus();
  303. event.preventDefault(); // prevent scrolling at the same time
  304. }
  305. }
  306. }
  307.  
  308. /**
  309. Moves the focus horizontally.
  310.  
  311. @private
  312. */
  313. _moveFocusHorizontally(left, event) {
  314. const currentLayoutData = event.target._layoutData;
  315. if (!currentLayoutData) {
  316. return;
  317. }
  318.  
  319. let nextItem;
  320. let items = this._masonry.items.getAll();
  321. let collectionItemIndex = items.indexOf(event.target);
  322.  
  323. if (left) {
  324. if (collectionItemIndex > 0) {
  325. nextItem = items[collectionItemIndex - 1];
  326. }
  327. } else if (collectionItemIndex < items.length - 1) {
  328. nextItem = items[collectionItemIndex + 1];
  329. }
  330.  
  331. if (nextItem) {
  332. nextItem.focus();
  333. event.preventDefault(); // prevent scrolling at the same time
  334. }
  335. }
  336.  
  337. /**
  338. Moves the focus to first or last item based on the visual order.
  339.  
  340. @private
  341. */
  342. _moveFocusHomeEnd(home, event) {
  343. const currentLayoutData = event.target._layoutData;
  344. if (!currentLayoutData) {
  345. return;
  346. }
  347.  
  348. let nextItem;
  349. const columns = this._columns;
  350.  
  351. // when home is pressed, we take the first item of the first column
  352. if (home) {
  353. nextItem = columns[0] && columns[0].items[0];
  354. } else {
  355. // when end is pressed, we take the last item of the last column; since some columns are empty, we need to
  356. // iterate backwards to find the first column that has items
  357. for (let i = columns.length - 1 ; i > -1 ; i--) {
  358. // since we found a column with items, we take the last item as the next one
  359. if (columns[i].items.length > 0) {
  360. nextItem = columns[i].items[columns[i].items.length - 1];
  361. break;
  362. }
  363. }
  364. }
  365.  
  366. if (nextItem) {
  367. nextItem.focus();
  368. // we prevent the scrolling
  369. event.preventDefault();
  370. }
  371. }
  372.  
  373. /** @inheritdoc */
  374. layout(secondTry) {
  375. const masonry = this._masonry;
  376.  
  377. const items = masonry.items.getAll();
  378. if (items.length > 0) {
  379. // For best possible performance none of these function calls must both read and write attributes in a loop to
  380. // avoid unnecessary reflows.
  381. this._init(items);
  382. this._writeStyles(items);
  383. this._readStyles(items);
  384. this._positionItems(items);
  385. } else {
  386. this._columns.length = 0;
  387. }
  388.  
  389. // Update the height of the masonry (otherwise it has a height of 0px due to the absolutely positioned items)
  390. const contentHeight = this._getContentHeight();
  391. masonry.style.height = `${contentHeight - this._verSpacing + this._verPadding}px`;
  392.  
  393. // Check if the masonry has changed its width due to the changed height (can happen because of appearing/disappearing scrollbars)
  394. if (!secondTry && this._masonryInnerWidth !== masonry.clientWidth) {
  395. this.layout(true);
  396. } else {
  397. // Post layout hook for sub classes
  398. this._postLayout(contentHeight);
  399. }
  400. }
  401.  
  402. /** @inheritdoc */
  403. destroy() {
  404. this._keys.destroy();
  405.  
  406. const items = this._masonry.items.getAll();
  407. for (let i = 0 ; i < items.length ; i++) {
  408. const item = items[i];
  409. item._layoutData = undefined;
  410. setTransform(item, '');
  411. this._resetItem(item);
  412. }
  413. }
  414.  
  415. /** @inheritdoc */
  416. detach(item) {
  417. item._layoutData.detached = true;
  418. }
  419.  
  420. /** @inheritdoc */
  421. reattach(item) {
  422. const layoutData = item._layoutData;
  423. layoutData.detached = false;
  424.  
  425. const rect = item.getBoundingClientRect();
  426. // Disable transition while repositioning
  427. setTransition(item, 'none');
  428. item.style.left = '';
  429. item.style.top = '';
  430. setTransform(item, '');
  431.  
  432. const nullRect = item.getBoundingClientRect();
  433. layoutData.left = rect.left - nullRect.left;
  434. layoutData.top = rect.top - nullRect.top;
  435. setTransform(item, `translate(${layoutData.left}px, ${layoutData.top}px)`);
  436. // Enforce position
  437. item.getBoundingClientRect();
  438. // Enable transition again
  439. setTransition(item, '');
  440. }
  441.  
  442. /** @inheritdoc */
  443. itemAt(x, y) {
  444. // TODO it would be more efficient to pick first the right column
  445. const items = this._masonry.items.getAll();
  446.  
  447. for (let i = 0 ; i < items.length ; i++) {
  448. const item = items[i];
  449. const layoutData = item._layoutData;
  450.  
  451. if (layoutData && !layoutData.ignored && (
  452. layoutData.left <= x && layoutData.left + layoutData.width >= x &&
  453. layoutData.top <= y && layoutData.top + layoutData.height >= y)) {
  454. return item;
  455. }
  456. }
  457.  
  458. return null;
  459. }
  460. }
  461.  
  462. export default MasonryColumnLayout;