UNPKG

@da3dsoul/react-stonemason

Version:

A true masonry layout container, with support for child components

578 lines (474 loc) 19.7 kB
import React, { cloneElement, useState, useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; import ResizeObserver from 'resize-observer-polyfill'; function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) { symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); } keys.push.apply(keys, symbols); } return keys; } function _objectSpread2(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); } function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); } function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); } function _iterableToArrayLimit(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; } function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } /* Copyright 2007-2013 Marijn Haverbeke frin "Eloquent Javascript, 1st Edition" Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ function BinaryHeap(scoreFunction) { this.content = []; this.scoreFunction = scoreFunction; } BinaryHeap.prototype = { push: function push(element) { // Add the new element to the end of the array. this.content.push(element); // Allow it to bubble up. this.bubbleUp(this.content.length - 1); }, pop: function pop() { // Store the first element so we can return it later. var result = this.content[0]; // Get the element at the end of the array. var end = this.content.pop(); // If there are any elements left, put the end element at the // start, and let it sink down. if (this.content.length > 0) { this.content[0] = end; this.sinkDown(0); } return result; }, remove: function remove(node) { var length = this.content.length; // To remove a value, we must search through the array to find // it. for (var i = 0; i < length; i++) { if (this.content[i] != node) continue; // When it is found, the process seen in 'pop' is repeated // to fill up the hole. var end = this.content.pop(); // If the element we popped was the one we needed to remove, // we're done. if (i == length - 1) break; // Otherwise, we replace the removed element with the popped // one, and allow it to float up or sink down as appropriate. this.content[i] = end; this.bubbleUp(i); this.sinkDown(i); break; } }, size: function size() { return this.content.length; }, bubbleUp: function bubbleUp(n) { // Fetch the element that has to be moved. var element = this.content[n], score = this.scoreFunction(element); // When at 0, an element can not go up any further. while (n > 0) { // Compute the parent element's index, and fetch it. var parentN = Math.floor((n + 1) / 2) - 1, parent = this.content[parentN]; // If the parent has a lesser score, things are in order and we // are done. if (score >= this.scoreFunction(parent)) break; // Otherwise, swap the parent with the current element and // continue. this.content[parentN] = element; this.content[n] = parent; n = parentN; } }, sinkDown: function sinkDown(n) { // Look up the target element and its score. var length = this.content.length, element = this.content[n], elemScore = this.scoreFunction(element); while (true) { // Compute the indices of the child elements. var child2N = (n + 1) * 2, child1N = child2N - 1; // This is used to store the new position of the element, // if any. var swap = null; // If the first child exists (is inside the array)... if (child1N < length) { // Look it up and compute its score. var child1 = this.content[child1N], child1Score = this.scoreFunction(child1); // If the score is less than our element's, we need to swap. if (child1Score < elemScore) swap = child1N; } // Do the same checks for the other child. if (child2N < length) { var child2 = this.content[child2N], child2Score = this.scoreFunction(child2); if (child2Score < (swap == null ? elemScore : child1Score)) swap = child2N; } // No need to swap further, we are done. if (swap == null) break; // Otherwise, swap and continue. this.content[n] = this.content[swap]; this.content[swap] = element; n = swap; } } }; var buildPrecedentsMap = function buildPrecedentsMap(graph, startNode, endNode) { // store the previous vertex of the shortest path of arrival var precedentsMap = {}; // store nodes already visited var visited = {}; // store/update only the shortest edge weights measured // the purpose of this is object is constant time lookup vs. binary heap lookup O(n) var storedShortestPaths = {}; storedShortestPaths[startNode] = 0; // priority queue of ALL nodes and storedShortestPaths // don't bother to delete them because it's faster to look at visited? var pQueue = new BinaryHeap(function (n) { return n.weight; }); pQueue.push({ id: startNode, weight: 0 }); while (pQueue.size()) { // pop node with shortest total weight from start node var shortestNode = pQueue.pop(); var shortestNodeId = shortestNode.id; // if already visited, continue if (visited[shortestNodeId]) continue; // visit neighboring nodes var neighboringNodes = graph(shortestNodeId) || {}; visited[shortestNodeId] = 1; // meet the neighbors, looking for shorter paths for (var neighbor in neighboringNodes) { // weight of path from startNode to this neighbor var newTotalWeight = shortestNode.weight + neighboringNodes[neighbor]; // if this is the first time meeting the neighbor OR if the new total weight from // start node to this neighbor node is greater than the old weight path, update it, // and update precedent node if (typeof storedShortestPaths[neighbor] === 'undefined' || storedShortestPaths[neighbor] > newTotalWeight) { storedShortestPaths[neighbor] = newTotalWeight; pQueue.push({ id: neighbor, weight: newTotalWeight }); precedentsMap[neighbor] = shortestNodeId; } } } if (typeof storedShortestPaths[endNode] === 'undefined') { throw new Error("There is no path from ".concat(startNode, " to ").concat(endNode)); } return precedentsMap; }; // build the route from precedent node vertices var getPathFromPrecedentsMap = function getPathFromPrecedentsMap(precedentsMap, endNode) { var nodes = []; var n = endNode; while (n) { nodes.push(n); n = precedentsMap[n]; } return nodes.reverse(); }; // build the precedentsMap and find the shortest path from it var findShortestPath = function findShortestPath(graph, startNode, endNode) { var precedentsMap = buildPrecedentsMap(graph, startNode, endNode); return getPathFromPrecedentsMap(precedentsMap, endNode); }; // to calculate the single best layout using Dijkstra's findShortestPat var round = function round(value, decimals) { if (!decimals) decimals = 0; return Number(Math.round(Number(value + 'e' + decimals)) + 'e-' + decimals); }; var ratio = function ratio(photo) { return round((photo.width || photo.props.width) / (photo.height || photo.props.height), 2); }; // get the height for a set of photos in a potential row var getCommonHeight = function getCommonHeight(row, containerWidth, margin, maxHeight) { var rowWidth = containerWidth - row.length * (margin * 2); var totalAspectRatio = row.reduce(function (acc, photo) { return acc + ratio(photo); }, 0); var commonHeight = round(rowWidth / totalAspectRatio); if (maxHeight > 0 && commonHeight > maxHeight) return maxHeight; return commonHeight; }; var cost = function cost(row, targetHeight, commonHeight) { return Math.pow(targetHeight - commonHeight, 2); }; // return function that gets the neighboring nodes of node and returns costs var makeGetNeighbors = function makeGetNeighbors(targetHeight, containerWidth, photos, limitNodeSearch, margin, minHeight, maxHeight, maxDeviation) { return function (start) { var results = {}; start = +start; results[+start] = 0; var _loop = function _loop(i) { if (i - start > limitNodeSearch) return "break"; var row = photos.slice(start, i); var commonHeight = getCommonHeight(row, containerWidth, margin, maxHeight); // adding more will only make the number smaller if (minHeight > 0 && commonHeight < minHeight) return "break"; var sumHeight = row.map(function (a) { return a.props.height; }).reduce(function (acc, curr) { return acc + curr; }); var averageHeight = sumHeight / row.length; var sDeviation = Math.sqrt(row.map(function (a) { return a.props.height; }).map(function (a) { return Math.pow(a - averageHeight, 2); }).reduce(function (acc, curr) { return acc + curr; }) / row.length); var deviation = sDeviation / commonHeight; // if we add more images to the row, then the deviation will only grow if (deviation > maxDeviation) return "break"; results[i.toString()] = cost(row, targetHeight, commonHeight); }; for (var i = start + 1; i < photos.length + 1; ++i) { var _ret = _loop(i); if (_ret === "break") break; } return results; }; }; // guesstimate how many neighboring nodes should be searched based on // the aspect ratio of the container with images having an avg AR of 1.5 // as the minimum amount of photos per row, plus some nodes var findIdealNodeSearch = function findIdealNodeSearch(_ref) { var targetRowHeight = _ref.targetRowHeight, containerWidth = _ref.containerWidth; var rowAR = containerWidth / targetRowHeight; return round(rowAR / 1.5) + 8; }; var computeRowLayout = function computeRowLayout(_ref2) { var containerWidth = _ref2.containerWidth, limitNodeSearch = _ref2.limitNodeSearch, targetRowHeight = _ref2.targetRowHeight, margin = _ref2.margin, photos = _ref2.photos, minHeight = _ref2.minHeight, maxHeight = _ref2.maxHeight, maxDeviation = _ref2.maxDeviation; // set how many neighboring nodes the graph will visit if (limitNodeSearch === undefined) { limitNodeSearch = 2; var dpiAdjustedWidth = Math.floor(containerWidth / window.devicePixelRatio); if (dpiAdjustedWidth >= 450) { limitNodeSearch = findIdealNodeSearch({ containerWidth: dpiAdjustedWidth, targetRowHeight: targetRowHeight }); } } var getNeighbors = makeGetNeighbors(targetRowHeight, containerWidth, photos, limitNodeSearch, margin, minHeight, maxHeight, maxDeviation); var path = findShortestPath(getNeighbors, '0', photos.length); path = path.map(function (node) { return +node; }); for (var i = 1; i < path.length; ++i) { var row = photos.slice(path[i - 1], path[i]); var height = getCommonHeight(row, containerWidth, margin, maxHeight); for (var j = path[i - 1]; j < path[i]; ++j) { var p = photos[j]; var newWidth = round(height * ratio(p), 1) + 'px'; var newHeight = round(height, 1) + 'px'; var newStyle = typeof margin === "undefined" ? { width: newWidth, height: newHeight } : { width: newWidth, height: newHeight, margin: margin + 'px' }; var style = p.style || p.props.style; var newProps = _objectSpread2(_objectSpread2({}, p.props || {}), {}, { style: _objectSpread2(_objectSpread2({}, style), newStyle) }); photos[j] = /*#__PURE__*/cloneElement.apply(void 0, [photos[j], newProps].concat(_toConsumableArray(p.props.children || []))); } } return photos; }; var Stonemason = function Stonemason(props) { var margin = props.margin; var limitNodeSearch = props.limitNodeSearch; var targetHeight = props.targetRowHeight; var minRowHeight = props.minRowHeight; var maxRowHeight = props.maxRowHeight; var maxDeviation = props.maxDeviation; var _useState = useState({ width: 0, height: 0 }), _useState2 = _slicedToArray(_useState, 2), containerSize = _useState2[0], setContainerSize = _useState2[1]; var StonemasonEl = useRef(null); var photos = props.children; useEffect(function () { var animationFrameID = null; var observer = new ResizeObserver(function (entries) { // only do something if width changes var newWidth = Math.floor(entries[0].contentRect.width); var newHeight = Math.floor(window.outerHeight); if (containerSize.width !== newWidth || containerSize.height !== newHeight) { // put in an animation frame to stop "benign errors" from // ResizeObserver https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded animationFrameID = window.requestAnimationFrame(function () { setContainerSize({ width: newWidth, height: newHeight }); }); } }); observer.observe(StonemasonEl.current); return function () { observer.disconnect(); window.cancelAnimationFrame(animationFrameID); }; }); // no containerWidth until after first render with refs, skip calculations and render nothing if (!containerSize.width) return /*#__PURE__*/React.createElement("div", { ref: StonemasonEl }, "\xA0"); // subtract 1 pixel because the browser may round up a pixel var width = containerSize.width - 1; var height = containerSize.height - 1; var StonemasonStyle = { display: 'flex', flexWrap: 'wrap', flexDirection: 'row' }; // allow user to calculate limitNodeSearch from containerWidth if (typeof limitNodeSearch === 'function') { limitNodeSearch = limitNodeSearch(width); } if (typeof targetHeight === 'function') { targetHeight = targetHeight(width); } else { targetHeight = props.allowOOB ? targetHeight : Math.min(targetHeight, height); } if (typeof minRowHeight === 'function') { minRowHeight = minRowHeight(width); } if (typeof maxRowHeight === 'function') { maxRowHeight = maxRowHeight(width); } if (typeof maxDeviation === 'function') { maxDeviation = maxDeviation(width, targetHeight); } photos = computeRowLayout({ containerWidth: width, limitNodeSearch: limitNodeSearch, targetRowHeight: targetHeight, maxDeviation: maxDeviation, margin: margin, photos: photos, minHeight: minRowHeight, maxHeight: maxRowHeight }); return /*#__PURE__*/React.createElement("div", { className: "react-stonemason--Stonemason" }, /*#__PURE__*/React.createElement("div", { ref: StonemasonEl, style: StonemasonStyle }, photos)); }; var imagePropType = PropTypes.oneOfType([PropTypes.shape({ key: PropTypes.string, props: PropTypes.shape({ width: PropTypes.number.isRequired, height: PropTypes.number.isRequired }) }), PropTypes.shape({ key: PropTypes.string, width: PropTypes.number.isRequired, height: PropTypes.number.isRequired })]); Stonemason.propTypes = { children: PropTypes.arrayOf(imagePropType).isRequired, targetRowHeight: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), minRowHeight: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), maxRowHeight: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), maxDeviation: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), limitNodeSearch: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), allowOOB: PropTypes.bool, margin: PropTypes.number }; Stonemason.defaultProps = { margin: 2, targetRowHeight: 300, allowOOB: false, minRowHeight: 0, maxRowHeight: 0, maxDeviation: 1 }; export default Stonemason;