├── .gitignore ├── .babelrc ├── example ├── images │ └── screenshot.png ├── js │ └── main.js ├── index.html ├── server.js └── webpack.config.js ├── test ├── bootstrap.js ├── ImageLayout.spec.js └── fixture.js ├── webpack.config.js ├── README.md ├── package.json ├── karma.config.js ├── .eslintrc └── src └── ImageLayout.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | npm-debug.log -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /example/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackargyle/react-image-layout/HEAD/example/images/screenshot.png -------------------------------------------------------------------------------- /example/js/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import ImageLayout from '../../src/ImageLayout'; 4 | import { defaultProps, skinnyProps, fatProps } from '../../test/fixture'; 5 | 6 | ReactDOM.render( 7 | , 8 | document.getElementById('app') 9 | ); 10 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-image-layout 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/bootstrap.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const sinon = require('sinon'); 3 | const enzyme = require('enzyme'); 4 | const chaiEnzyme = require('chai-enzyme'); 5 | const React = require('react'); 6 | 7 | chai.use(chaiEnzyme()); 8 | 9 | global.sinon = sinon; 10 | global.React = React; 11 | global.enzyme = enzyme; 12 | global.expect = chai.expect; 13 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | var config = require('./webpack.config'); 4 | 5 | new WebpackDevServer(webpack(config), { 6 | publicPath: config.output.publicPath, 7 | hot: true, 8 | }).listen(3000, 'localhost', function (err, result) { 9 | if (err) { console.log(err) } 10 | console.log('Listening at localhost:3000'); 11 | }); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var webpack = require('webpack'); 4 | 5 | module.exports = { 6 | entry: './src/ImageLayout.js', 7 | module: { 8 | loaders: [{ 9 | test: /\.js$/, 10 | loaders: ['babel-loader'], 11 | exclude: /node_modules/, 12 | }], 13 | preLoaders: [{ 14 | test: /\.js$/, 15 | loaders: ['eslint-loader'], 16 | include: ['./src'] 17 | }] 18 | }, 19 | output: { 20 | path: __dirname + "/dist", 21 | filename: "ImageLayout.min.js", 22 | library: 'ImageLayout', 23 | libraryTarget: 'umd' 24 | }, 25 | externals: { 26 | react: 'react' 27 | }, 28 | plugins: [ 29 | new webpack.optimize.OccurenceOrderPlugin(), 30 | new webpack.optimize.UglifyJsPlugin({ minimize: true }), 31 | ], 32 | eslint: { 33 | configFile: './.eslintrc' 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: "source-map", 6 | entry: [ 7 | 'babel-polyfill', 8 | 'webpack-dev-server/client?http://0.0.0.0:3000', 9 | 'webpack/hot/only-dev-server', 10 | path.join(__dirname, 'js') + '/main.js' 11 | ], 12 | output: { 13 | filename: 'bundle.js', 14 | path: path.join(__dirname, 'dist'), 15 | publicPath: '/dist/' 16 | }, 17 | module: { 18 | loaders: [ 19 | { test: /\.js$/, loaders: ['react-hot', 'babel-loader'], exclude: /node_modules/ }, 20 | { test: /\.css$/, loader: 'style-loader!css-loader' } 21 | ], 22 | preLoaders: [{ 23 | test: /\.js$/, 24 | loaders: ['eslint-loader'], 25 | exclude: /node_modules/ 26 | }] 27 | }, 28 | plugins: [ 29 | new webpack.HotModuleReplacementPlugin() 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## react-image-layout 2 | 3 | ## Install 4 | 5 | ``` js 6 | npm install react-image-layout --save 7 | ``` 8 | 9 | ![](https://raw.githubusercontent.com/zackargyle/react-image-layout/master/example/images/screenshot.png) 10 | 11 | ## ImageLayout Props 12 | 13 | prop | type | default | notes 14 | ----------- | ------ | ---------- | ---------- 15 | gutter | number | 0 | the margin between grid elements 16 | columns | number | 4 | the number of columns to use in the grid 17 | columnWidth | number | 100 | the fixed width of the columns 18 | items | Array | `required` | the list of image objects 19 | 20 | Use: 21 | ``` js 22 | 23 | import ImageLayout from 'react-image-layout'; 24 | 25 | 26 | 27 | ``` 28 | 29 | ## Scripts 30 | script | description 31 | -------------- | ----------- 32 | `npm start` | run the example on `localhost:3000` 33 | `npm test` | run the component tests 34 | `npm build` | build the compiled/minified version 35 | `npm run lint` | run the linter on the `/src` directory 36 | 37 | ## License 38 | [MIT](http://isekivacenz.mit-license.org/) 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-image-layout", 3 | "version": "1.0.0", 4 | "description": "A simple Pinterest style grid layout component for images", 5 | "main": "dist/ImageLayout.min.js", 6 | "jsnext:main": "src/ImageLayout.js", 7 | "scripts": { 8 | "build": "npm run clean && webpack --config webpack.config.js", 9 | "clean": "rimraf dist lib", 10 | "lint": "./node_modules/eslint/bin/eslint.js src", 11 | "prepublish": "npm run build", 12 | "start": "cd example && node server.js", 13 | "test": "node_modules/.bin/karma start karma.config.js" 14 | }, 15 | "devDependencies": { 16 | "babel-core": "^6.3.13", 17 | "babel-eslint": "^4.1.8", 18 | "babel-loader": "^6.2.2", 19 | "babel-polyfill": "^6.5.0", 20 | "babel-preset-es2015": "^6.3.13", 21 | "babel-preset-react": "^6.3.13", 22 | "chai": "^3.5.0", 23 | "chai-enzyme": "^0.4.0", 24 | "cheerio": "^0.20.0", 25 | "enzyme": "^1.6.0", 26 | "eslint": "^1.6.0", 27 | "eslint-loader": "^1.2.1", 28 | "eslint-plugin-react": "^3.16.1", 29 | "karma": "^0.13.19", 30 | "karma-chai": "^0.1.0", 31 | "karma-mocha": "^0.2.1", 32 | "karma-phantomjs-launcher": "^1.0.0", 33 | "karma-sourcemap-loader": "^0.3.7", 34 | "karma-spec-reporter": "0.0.24", 35 | "karma-webpack": "^1.7.0", 36 | "mocha": "^2.4.5", 37 | "phantomjs": "^2.1.3", 38 | "phantomjs-polyfill": "0.0.1", 39 | "phantomjs-prebuilt": "^2.1.4", 40 | "react-addons-test-utils": "^0.14.7", 41 | "react-dom": "^0.14.7", 42 | "react-hot-loader": "^1.3.0", 43 | "rimraf": "^2.4.4", 44 | "sinon": "^1.17.3", 45 | "webpack": "^1.12.13", 46 | "webpack-dev-server": "^1.14.1", 47 | "yargs": "^3.32.0" 48 | }, 49 | "peerDependencies": { 50 | "react": "^0.14.3" 51 | }, 52 | "files": [ 53 | "dist/", 54 | "src/" 55 | ], 56 | "repository": { 57 | "type": "git", 58 | "url": "https://github.com/zackargyle/react-image-layout.git" 59 | }, 60 | "keywords": [ 61 | "react", 62 | "react-component" 63 | ], 64 | "author": "Zack Argyle (http://zackargyle.com)", 65 | "license": "ISC", 66 | "bugs": { 67 | "url": "https://github.com/zackargyle/react-image-layout/" 68 | }, 69 | "homepage": "https://github.com/zackargyle/react-image-layout#readme" 70 | } 71 | -------------------------------------------------------------------------------- /karma.config.js: -------------------------------------------------------------------------------- 1 | var argv = require('yargs').argv; 2 | var webpack = require('webpack'); 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | // only use PhantomJS for our 'test' browser 7 | browsers: ['PhantomJS'], 8 | 9 | // just run once by default unless --watch flag is passed 10 | singleRun: !argv.watch, 11 | 12 | // which karma frameworks do we want integrated 13 | frameworks: ['mocha', 'chai'], 14 | 15 | // displays tests in a nice readable format 16 | reporters: ['spec'], 17 | 18 | // include some polyfills for babel and phantomjs 19 | files: [ 20 | 'node_modules/babel-polyfill/dist/polyfill.js', 21 | './node_modules/phantomjs-polyfill/bind-polyfill.js', 22 | './test/bootstrap.js', 23 | './test/**/*.spec.js' 24 | ], 25 | preprocessors: { 26 | // these files we want to be precompiled with webpack 27 | // also run tests throug sourcemap for easier debugging 28 | './test/**/*.js': ['webpack', 'sourcemap'] 29 | }, 30 | webpack: { 31 | plugins: [ 32 | new webpack.optimize.OccurenceOrderPlugin(), 33 | new webpack.DefinePlugin({ IS_WEBPACK: false, }), 34 | new webpack.NoErrorsPlugin() 35 | ], 36 | resolve: { 37 | // required for enzyme to work properly 38 | alias: { 39 | 'sinon': 'sinon/pkg/sinon' 40 | } 41 | }, 42 | module: { 43 | // don't run babel-loader through the sinon module 44 | noParse: [ 45 | /node_modules\/sinon\// 46 | ], 47 | loaders: [ 48 | { test: /\.js$/, loaders: ['babel-loader'], exclude: /node_modules/ } 49 | ] 50 | }, 51 | // required for enzyme to work properly 52 | externals: { 53 | 'jsdom': 'window', 54 | 'cheerio': 'window', 55 | 'react/lib/ExecutionEnvironment': true, 56 | 'react/lib/ReactContext': 'window' 57 | }, 58 | }, 59 | webpackMiddleware: { 60 | noInfo: true 61 | }, 62 | // tell karma all the plugins we're going to be using to prevent warnings 63 | plugins: [ 64 | 'karma-mocha', 65 | 'karma-chai', 66 | 'karma-webpack', 67 | 'karma-phantomjs-launcher', 68 | 'karma-spec-reporter', 69 | 'karma-sourcemap-loader' 70 | ] 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /test/ImageLayout.spec.js: -------------------------------------------------------------------------------- 1 | const { shallow } = require('enzyme'); 2 | 3 | const ImageLayout = require('../src/ImageLayout').default; 4 | const fixture = require('./fixture'); 5 | const defaultProps = fixture.defaultProps; 6 | 7 | describe('ImageLayout - Component', () => { 8 | let wrapper; 9 | 10 | beforeEach(() => { 11 | wrapper = shallow(); 12 | }); 13 | 14 | it('should have the `ImageLayout` classname', () => { 15 | expect(wrapper.hasClass('ImageLayout')).to.be.true; 16 | }); 17 | 18 | it('should set the container to be relative', () => { 19 | expect(wrapper.props().style.position).to.equal('relative'); 20 | }); 21 | 22 | it('should render an for each item', () => { 23 | expect(wrapper.find('.ImageLayout__item')).to.have.length(defaultProps.items.length); 24 | }); 25 | 26 | }); 27 | 28 | describe('ImageLayout.getShortestColumn', () => { 29 | let instance; 30 | 31 | beforeEach(() => { 32 | instance = shallow().instance(); 33 | }); 34 | 35 | it('should get the correct shortest column from start', () => { 36 | instance.columnHeights = [0, 0, 0, 0]; 37 | expect(instance.getShortestColumn()).to.equal(0); 38 | }); 39 | 40 | it('should get the correct shortest column from end', () => { 41 | instance.columnHeights = [200, 250, 200, 100]; 42 | expect(instance.getShortestColumn()).to.equal(3); 43 | }); 44 | 45 | it('should get the correct shortest column from mid', () => { 46 | instance.columnHeights = [100, 100, 50, 100]; 47 | expect(instance.getShortestColumn()).to.equal(2); 48 | }); 49 | }) 50 | 51 | describe('ImageLayout.getItemStyle', () => { 52 | let instance, style; 53 | 54 | function render(item) { 55 | instance = shallow().instance(); 56 | instance.columnHeights = [100, 200, 50, 300]; 57 | style = instance.getItemStyle(item); 58 | } 59 | 60 | it('should set the correct style for an item', () => { 61 | render({ url: '', height: 400, width: 100 }); 62 | expect(style.left).to.equal('200px'); 63 | expect(style.top).to.equal('50px'); 64 | expect(style.position).to.equal('absolute'); 65 | }); 66 | 67 | it('should add the height to the columnHeights', () => { 68 | render({ url: '', height: 400, width: 100 }); 69 | expect(instance.columnHeights[2]).to.equal(50 + 400); 70 | }); 71 | 72 | it('should normalize the height in columnHeights', () => { 73 | render({ url: '', height: 400, width: 200 }); // width is 2x default 74 | expect(instance.columnHeights[2]).to.equal(50 + 400 / 2); 75 | }); 76 | }); 77 | 78 | -------------------------------------------------------------------------------- /test/fixture.js: -------------------------------------------------------------------------------- 1 | const items = [ 2 | { 3 | url: "https://s-media-cache-ak0.pinimg.com/237x/f3/53/9b/f3539ba51635b064a5139612345f7915.jpg", 4 | width: 237, 5 | height: 239 6 | }, 7 | { 8 | url: "https://s-media-cache-ak0.pinimg.com/237x/83/e1/82/83e18247abb6879440307aca94e77e6a.jpg", 9 | width: 237, 10 | height: 429 11 | }, 12 | { 13 | url: "https://s-media-cache-ak0.pinimg.com/237x/04/fb/7a/04fb7a14eaf2b2162a3e1b150e9f9131.jpg", 14 | width: 237, 15 | height: 355 16 | }, 17 | { 18 | url: "https://s-media-cache-ak0.pinimg.com/237x/6d/79/f4/6d79f436684a1bd7dbf79c7478113319.jpg", 19 | width: 237, 20 | height: 249 21 | }, 22 | { 23 | url: "https://s-media-cache-ak0.pinimg.com/237x/02/3b/a7/023ba7ba224548a2a2112ad1552c070c.jpg", 24 | width: 237, 25 | height: 237 26 | }, 27 | { 28 | url: "https://s-media-cache-ak0.pinimg.com/237x/dc/38/32/dc38322c256e521c419f652ac89cc476.jpg", 29 | width: 237, 30 | height: 505 31 | }, 32 | { 33 | url: "https://s-media-cache-ak0.pinimg.com/237x/d2/dd/c3/d2ddc3c92d60a326b4227f507a0bb572.jpg", 34 | width: 237, 35 | height: 740 36 | }, 37 | { 38 | url: "https://s-media-cache-ak0.pinimg.com/237x/39/01/60/390160f4a18d0ab81036d03e70ce18c3.jpg", 39 | width: 237, 40 | height: 237 41 | }, 42 | { 43 | url: "https://s-media-cache-ak0.pinimg.com/237x/c1/e3/72/c1e372d92a9d58481ef7fe60d0378424.jpg", 44 | width: 237, 45 | height: 315 46 | }, 47 | { 48 | url: "https://s-media-cache-ak0.pinimg.com/237x/83/cb/6e/83cb6e4a34d98d3f82d7bd23af15752f.jpg", 49 | width: 237, 50 | height: 237 51 | }, 52 | { 53 | url: "https://s-media-cache-ak0.pinimg.com/237x/af/82/bb/af82bb8dd1ced8cb80f4a1caae534996.jpg", 54 | width: 237, 55 | height: 315 56 | }, 57 | { 58 | url: "https://s-media-cache-ak0.pinimg.com/237x/8b/53/8d/8b538dbea071b8a7027a3cd89beb7ccc.jpg", 59 | width: 237, 60 | height: 354 61 | }, 62 | { 63 | url: "https://s-media-cache-ak0.pinimg.com/237x/04/f7/b7/04f7b7ef4c4afc552980ceb3d00e234f.jpg", 64 | width: 237, 65 | height: 583 66 | }, 67 | { 68 | url: "https://s-media-cache-ak0.pinimg.com/237x/4f/c5/f1/4fc5f1dad619269ee68de829d3c9afcd.jpg", 69 | width: 237, 70 | height: 330 71 | }, 72 | { 73 | url: "https://s-media-cache-ak0.pinimg.com/237x/07/3f/06/073f0641f187ee40cca608a4d83c435c.jpg", 74 | width: 237, 75 | height: 255 76 | }, 77 | ]; 78 | 79 | export const defaultProps = { 80 | columns: 5, 81 | columnWidth: 200, 82 | gutter: 5, 83 | items: items 84 | }; 85 | 86 | export const skinnyProps = Object.assign({}, defaultProps, { 87 | columns: 2, 88 | columnWidth: 100 89 | }); 90 | 91 | export const fatProps = Object.assign({}, defaultProps, { 92 | columns: 3, 93 | columnWidth: 400 94 | }); 95 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | 8 | "plugins": ["react"], 9 | 10 | "ecmaFeatures": { 11 | "arrowFunctions": true, 12 | "binaryLiterals": true, 13 | "blockBindings": true, 14 | "classes": true, 15 | "defaultParams": true, 16 | "destructuring": true, 17 | "experimentalObjectRestSpread": true, 18 | "forOf": true, 19 | "generators": true, 20 | "modules": true, 21 | "objectLiteralComputedProperties": true, 22 | "objectLiteralDuplicateProperties": true, 23 | "objectLiteralShorthandMethods": true, 24 | "objectLiteralShorthandProperties": true, 25 | "octalLiterals": true, 26 | "spread": true, 27 | "superInFunctions": true, 28 | "templateStrings": true, 29 | "unicodeCodePointEscapes": true, 30 | "globalReturn": true, 31 | "jsx": true 32 | }, 33 | 34 | "rules": { 35 | // JS 36 | "camelcase": 0, 37 | "consistent-return": 0, 38 | "dot-notation": 0, 39 | "eol-last": 0, 40 | "global-strict": 0, 41 | "key-spacing": 0, 42 | "new-cap": 0, 43 | "no-alert": 0, 44 | "comma-dangle": 0, 45 | "no-console": 0, 46 | "no-constant-condition": 0, 47 | "no-empty": 0, 48 | "no-extend-native": 0, 49 | "no-multi-spaces": 0, 50 | "no-octal-escape": 0, 51 | "no-process-exit": 0, 52 | "no-script-url": 0, 53 | "no-shadow": 0, 54 | "no-trailing-spaces": 0, 55 | "no-underscore-dangle": 0, 56 | "no-unused-expressions": 0, 57 | "no-unused-vars": 1, 58 | "semi-spacing": 0, 59 | "strict": 0, 60 | "space-unary-ops": 0, 61 | "quotes": 0, 62 | "yoda": 0, 63 | 64 | // React 65 | "jsx-quotes": [2, "prefer-double"], // Enforce quote style for JSX attributes 66 | "react/jsx-no-undef": 2, // Disallow undeclared variables in JSX 67 | "react/jsx-pascal-case": 2, 68 | "react/jsx-sort-props": 0, // Enforce props alphabetical sorting 69 | "react/jsx-uses-react": 2, // Prevent React to be incorrectly marked as unused 70 | "react/jsx-uses-vars": 2, // Prevent variables used in JSX to be incorrectly marked as unused 71 | "react/no-did-mount-set-state": 2, // Prevent usage of setState in componentDidMount 72 | "react/no-did-update-set-state": 2, // Prevent usage of setState in componentDidUpdate 73 | "react/no-multi-comp": 0, // Prevent multiple component definition per file 74 | "react/no-unknown-property": 2, // Prevent usage of unknown DOM property 75 | "react/prefer-es6-class": 2, 76 | "react/prop-types": 2, // Prevent missing props validation in a React component definition 77 | "react/react-in-jsx-scope": 2, // Prevent missing React when using JSX 78 | "react/self-closing-comp": 2, // Prevent extra closing tags for components without children 79 | "react/wrap-multilines": 2, // Prevent missing parentheses around multilines JSX 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ImageLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* 4 | * The classic "masonry" style Pinterest grid 5 | * @prop {number} columns - the number of columns in the grid 6 | * @prop {number} columnWidth - the fixed width of the columns 7 | * @prop {number} gutter - the number of columns in the grid 8 | * @prop {Array} items - the list of items to render 9 | */ 10 | export default class ImageLayout extends React.Component { 11 | 12 | constructor(props) { 13 | super(props); 14 | this.columnHeights = Array.from({ length: props.columns }, () => 0); 15 | this.renderItem = this.renderItem.bind(this); 16 | } 17 | 18 | /* 19 | * Get the shortest column in the list of columns heights 20 | */ 21 | getShortestColumn() { 22 | const minValue = Math.min(...this.columnHeights); 23 | return this.columnHeights.indexOf(minValue); 24 | } 25 | 26 | /* 27 | * Determine the top and left positions of the grid item. Update the 28 | * cached column height. 29 | * @param {Object} item - the grid item 30 | * @param {Object} item.height - the grid item's image height 31 | * @param {Object} item.width - the grid item's image width 32 | */ 33 | getItemStyle(item) { 34 | const { columnWidth, gutter } = this.props; 35 | const shortestColumnIndex = this.getShortestColumn(); 36 | const left = ( columnWidth + gutter ) * shortestColumnIndex; 37 | const top = this.columnHeights[shortestColumnIndex]; 38 | const normalizedHeight = (columnWidth / item.width) * item.height; 39 | this.columnHeights[shortestColumnIndex] += normalizedHeight + gutter; 40 | return { 41 | left: `${left}px`, 42 | top: `${top}px`, 43 | position: `absolute` 44 | }; 45 | } 46 | 47 | /* 48 | * Render helper for an individual grid item 49 | * @param {Object} item - the grid item to render 50 | * @param {Object} item.url - the image src url 51 | */ 52 | renderItem(item, index) { 53 | return ( 54 | 59 | ); 60 | } 61 | 62 | render() { 63 | const { items } = this.props; 64 | return ( 65 |
66 | { items.map(this.renderItem) } 67 |
68 | ) 69 | } 70 | } 71 | 72 | ImageLayout.propTypes = { 73 | // The number of columns in the grid 74 | columns: React.PropTypes.number, 75 | // The fixed width of the columns in the grid 76 | columnWidth: React.PropTypes.number, 77 | // The size of the gutter between images 78 | gutter: React.PropTypes.number, 79 | // The list of images to render 80 | items: React.PropTypes.arrayOf( 81 | React.PropTypes.shape({ 82 | height: React.PropTypes.number.isRequired, 83 | url: React.PropTypes.string.isRequired, 84 | width: React.PropTypes.number.isRequired 85 | }) 86 | ).isRequired 87 | }; 88 | 89 | ImageLayout.defaultProps = { 90 | columns: 4, 91 | columnWidth: 100, 92 | gutter: 0 93 | }; 94 | --------------------------------------------------------------------------------