├── .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 | 
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 |
--------------------------------------------------------------------------------