| 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421 | 13×
2×
1×
1×
1×
1×
1×
1×
1×
1×
1×
1×
1×
| import React from 'react';IEE
import Plank from './plank';EI
/**
* Container for planks. Handles the sizing, positioning, and responsiveness for all individual planks.
*
* @param Object props.options
*/
export default class Planks extends React.Component {
constructor(props) {
super(props);
let breakpointKey = this.getCurrentBreakpointKey();
// let planksRendered = {};
/*planksRendered[breakpointKey] = {
hiddenPlanksRendered: false,
heightsSet: false
};*/
this.validateConfigOptions();
this.state = {
plankWidths: {}, // Caches all the plank widths per breakpoint
plankHeights: {}, // Caches and used to determine when to unhide
plankPosition: {}, // The calculated positions for each plank
containerWidth: 0, // Used to determine widths of planks based on current screen breakpoint
containerHeights: {}, // Must set height because container element is absolute
breakpointKey: breakpointKey, // Current responsive breakpoint
allPlanksRendered: {} // Caches when to unhide by breakpoint
};
}
/**
* When this component first mounts, no data has been loaded yet. Therefore, no dimensions of the children
* components can be known. The objective here is to simply get the widths of the child planks so that they can
* be quickly rendered.
*/
componentDidMount() {
// console.log('[PLANKS CONTAINER] COMPONENT DID MOUNT...');
let containerWidth = this.getPlankContainerWidth();
let plankWidths = this.getSinglePlankWidths(containerWidth);
let planksRendered = this.state.allPlanksRendered;
// console.log('[PLANKS CONTAINER] INITIAL CONTAINER WIDTH: ' + containerWidth);
// console.log('[PLANKS CONTAINER] INITIAL PLANK WIDTH: ' + plankWidths[this.state.breakpointKey]);
// Event listener to calculate dimensions and positions on re-size. Caches results for faster subsequent
// rendering.
window.addEventListener('resize', this.handleResize.bind(this));
this.setState({
plankWidths: plankWidths,
containerWidth: containerWidth,
allPlanksRendered: planksRendered
});
// Force update so that all children's state gets synced with the new width.
// console.log('[PLANKS CONTAINER] FORCE UPDATE');
this.forceUpdate();
}
/**
* There are two phases of rendering individual child planks:
* 1. When the width is first set and they are rendered hidden and without positioning.
* 2. When all their heights have been received and positions have been calculated.
*/
shouldComponentUpdate() {
// console.log('[PLANKS CONTAINER] SHOULD COMPONENT UPDATE?');
if (!this.state.allPlanksRendered[this.state.containerWidth] ||
!this.state.allPlanksRendered[this.state.containerWidth].hiddenPlanksRendered) {
// console.log('[PLANKS CONTAINER] YES, INVISIBLE PLANKS HAVE NOT BEEN RENDERED YET');
return true;
}
if (!this.state.allPlanksRendered[this.state.containerWidth] ||
!this.state.allPlanksRendered[this.state.containerWidth].heightsSet) {
// console.log('[PLANKS CONTAINER] NO, ALL HEIGHTS HAVE NOT BEEN RECEIVED. BYPASSING UPDATE');
return false;
}
return true;
}
componentWillUnmount() {
}
/**
* TODO -- move this into it's own module that you include outside of Plank's scope.
*
* Checks to make sure the options passed in as this.props of this component are valid. Issue warnings and use
* defaults if they are not valid.
*/
validateConfigOptions() {
// TODO -- check to make sure keys are screen widths in numbers. issue a warning if they aren't. they don't
// have to be in order.
}
/**
* @return Number the current width of the referenced container element.
*/
getPlankContainerWidth() {
return this._planksContainer.offsetWidth;
}
/**
* Returns the current breakpoint key. Use this only when absolutely necessary such as on initial page load and
* when a responsive screen re-size has occured.
*/
getCurrentBreakpointKey() {
let screenWidth = document.body.offsetWidth;
let breakpointKey;
let optionKeys = [];
// Since for...in loops do not guarantee order, extract the keys
// into an array that can then be sorted in ascending order. If the current screen size is larger than any
// defined key, the column count will be associated to the largest key.
for (let key in this.props.options.breakpoints) {
optionKeys.push(key);
}
optionKeys.sort((a, b) => +a - +b);
breakpointKey = optionKeys[optionKeys.length - 1];
// If optionKeys is length 0, then we will use the largest key as assigned by the expression above,
// otherwise take the 0 index value as that by definition is the nearest breakpoint to the current screen size.
optionKeys = optionKeys.filter((item) => screenWidth < item);
if (optionKeys.length > 0) {
breakpointKey = optionKeys[0];
}
return breakpointKey;
}
/**
* Checks if width has previously been calculated and returns the cached value. Otherwise this calculates the width
* taking into account the current breakpoint, number of columns associated to the breakpoint, and horizontal
* padding.
*
* @param Number containerWidth the current container width
* @param String nextBreakpointKey hacky optional param to set the next value when a resize occurs
* @return Object plankWidths cache of breakpoint keys to plank widths
*/
getSinglePlankWidths(containerWidth, nextBreakpointKey) {
let plankWidths = this.state.plankWidths;
let breakpointKey = nextBreakpointKey || this.state.breakpointKey;
let numColumns;
let singlePlankWidth;
// Check for cached value
if (plankWidths[containerWidth] && plankWidths[containerWidth].length > 0) {
return plankWidths[containerWidth];
}
// To get the width of a single plank, the horizontal padding must be accounted for.
numColumns = this.props.options.breakpoints[breakpointKey + ''];
// 16px === 1rem
let hPadding = this.props.options.horizontalPadding;
let unitRatio = this.props.options.unitType === 'rem' ? 16 : 1;
singlePlankWidth = (containerWidth - ((numColumns - 1) * hPadding * unitRatio)) / numColumns / unitRatio;
// Cache the current value against the breakpoint key
plankWidths[containerWidth] = singlePlankWidth;
return plankWidths;
}
/**
* For optimization we need a flag to determine when all the hidden planks have been rendered. This allows us to
* skip re-rendering until all the hidden planks have been rendered.
*/
handleAllHiddenPlanksRendered() {
let planksRendered = this.state.allPlanksRendered;
// console.log('[PLANKS CONTAINER] LAST HIDDEN PLANK RENDERED');
if (!planksRendered[this.state.containerWidth]) {
planksRendered[this.state.containerWidth] = {};
}
planksRendered[this.state.containerWidth].hiddenPlanksRendered = true;
this.setState({ allPlanksRendered: planksRendered });
return true;
}
/**
* Screen resizes must handle the re-ordering of cards. If this ordering has already occurred for the existing
* data set, use a locally cached version of that particular ordering.
*
* TODO -- handle orientationchange event for mobile
*/
handleResize() {
let containerWidth = this.getPlankContainerWidth();
// Screen widths alone do not determine when a responsive change has occured. Responsiveness is a set of fixed
// container widths that exist over ranges of screen sizes. Handling resize is more optimal when
// calculations only occur when container width changes. Additionally, setting the current
// breakpointKey should only occur here and in the constructor.
if (this.state.containerWidth !== containerWidth) {
// console.log('[PLANKS CONTAINER] RESIZE DETECTED...');
let breakpointKey = this.getCurrentBreakpointKey();
// console.log('breakpointKey: ', breakpointKey);
// console.log('containerWidth: ', containerWidth);
let planksRendered = this.state.allPlanksRendered;
let plankWidths = this.getSinglePlankWidths(containerWidth, breakpointKey);
// console.log('planksWidths', plankWidths);
// console.log('planksRendered');
console.table(planksRendered);
if (planksRendered[breakpointKey] !== undefined) {
this.setState({
breakpointKey: breakpointKey,
containerWidth: containerWidth
});
return; // Cache exists. Do nothing.
}
planksRendered[breakpointKey] = {
hiddenPlanksRendered: false,
heightsSet: false
};
this.setState({
breakpointKey: breakpointKey,
plankWidths: plankWidths,
containerWidth: containerWidth,
allPlanksRendered: planksRendered
});
}
}
/**
* A callback that receives the hidden child element's height. When all heights are received, we can then calculate
* their correct positioning and set their visibility. This callback should execute one time per element render.
* This means that responsiveness should trigger a new render. However, we have to also account for cached values.
*/
receiveChildHeight(key, childHeight) {
let plankHeights = this.state.plankHeights;
let planksRendered = this.state.allPlanksRendered;
let renderAllPlanks;
// console.log('[PLANKS CONTAINER] RECEIVING CHILD HEIGHT:');
// console.log('[PLANKS CONTAINER] KEY: ' + key, 'HEIGHT: ' + childHeight);
if (plankHeights[this.state.containerWidth] === undefined) {
plankHeights[this.state.containerWidth] = {};
}
// It is very normal for updated heights to come back to us. This could indicated another image loaded, an
// image failing to load, or even new content. In this case we must force update if all the other heights
// have already been received.
let heightExists = plankHeights[this.state.containerWidth][key] || false;
plankHeights[this.state.containerWidth][key] = childHeight;
this.setState({ plankHeights: plankHeights });
if (heightExists && this.state.allPlanksRendered[this.state.containerWidth].heightsSet) {
// console.log('[PLANKS CONTAINER] UPDATING EXISTING HEIGHT. FORCE UPDATE');
this.setPlankPositioning();
this.forceUpdate();
}
// Render planks when all the heights have been received.
renderAllPlanks = Object.keys(plankHeights[this.state.containerWidth]).length === this.props.children.length;
// Initialize the rendering flags that corresponds to the two phases of child plank rendering.
if (!planksRendered[this.state.containerWidth]) {
planksRendered[this.state.containerWidth] = {
hiddenPlanksRendered: false,
heightsSet: false
};
}
// once we have all the heights of each plank, we can now calculate their absolute positioning.
if (renderAllPlanks) {
this.setPlankPositioning();
planksRendered[this.state.containerWidth].heightsSet = true;
}
this.setState({
allPlanksRendered: planksRendered
});
}
/**
* Set the plank positioning. Position sorting occurs here.
*/
setPlankPositioning() {
let plankPosition = this.state.plankPosition;
if (!plankPosition[this.state.containerWidth]) {
plankPosition[this.state.containerWidth] = {};
}
let numColumns = this.props.options.breakpoints[this.state.breakpointKey];
let columnHeights = [];
let colHeightPadding = []; // Keeps track of the accumulated vertical padding;
for (let i = 0; i < numColumns; i++) {
columnHeights[i] = colHeightPadding[i] = 0;
}
// iterate from left col to right col looking for the shortest col.
// if all cols are equal height, place in the left most col.
let [i, col, left] = [0, 0, 0];
let plankWidth = this.state.plankWidths[this.state.containerWidth];
let hPadding = this.props.options.horizontalPadding;
let vPadding = this.props.options.verticalPadding;
let unitRatio = this.props.options.unitType === 'rem' ? 16 : 1;
for (; i < this.props.children.length; i++) {
let plankHeight = this.state.plankHeights[this.state.containerWidth][i];
let positionStyle = {};
let shortestColHeight = Math.min.apply(Math, columnHeights);
let shortestColIndex = columnHeights.indexOf(Math.min.apply(Math, columnHeights));
let leftProperty = shortestColIndex * plankWidth + shortestColIndex * hPadding;
let topProperty = shortestColHeight / unitRatio + colHeightPadding[shortestColIndex];
positionStyle = {
left: leftProperty,
top: topProperty
};
plankPosition[this.state.containerWidth][i] = positionStyle;
columnHeights[shortestColIndex] += plankHeight;
colHeightPadding[shortestColIndex] += vPadding;
if (col === numColumns - 1) {
col = left = 0;
}
}
let greatestHeight = columnHeights.reduce((prev, cur) => prev > cur ? prev : cur);
let containerHeights = this.state.containerHeights;
greatestHeight += Math.max.apply(Math, colHeightPadding) * unitRatio;
containerHeights[this.state.containerWidth] = greatestHeight;
this.setState({
plankPosition: plankPosition,
containerHeights: containerHeights
});
}
getPlankStyles(index) {
if (!this.state.allPlanksRendered[this.state.containerWidth] ||
!this.state.allPlanksRendered[this.state.containerWidth].heightsSet) {
return {
position: 'absolute',
visibility: 'hidden',
width: this.state.plankWidths[this.state.containerWidth] + this.props.options.unitType
};
} else {
let positionStyles = this.state.plankPosition[this.state.containerWidth][index + ''];
return {
position: 'absolute',
visibility: 'visible',
width: this.state.plankWidths[this.state.containerWidth] + this.props.options.unitType,
left: positionStyles.left + 'rem',
top: positionStyles.top + 'rem'
};
}
}
/**
* Child styles are fetched upon rendering in order to allow for responsiveness. These styles have already been
* calculated and are simply set here to render based on the current responsive breakpoint.
*/
render() {
// console.log('[PLANKS CONTAINER] CONTAINER RENDERING...');
// console.log('[PLANKS CONTAINER] CURRENT CHILD PLANK WIDTH: ' + this.state.plankWidths[this.state.breakpointKey]);
let containerStyle = {
height: this.state.containerHeights[this.state.containerWidth],
position: 'relative'
};
let planks;
if (React.Children.count(this.props.children) > 1) {
planks = this.props.children.map((plank, index) => {
let styles = this.getPlankStyles(index);
let lastPlankHandler = index === React.Children.count(this.props.children) - 1
? this.handleAllHiddenPlanksRendered.bind(this)
: (() => null);
return (
<Plank
key={ index }
index={ index }
plankStyles={ styles }
plankWidth={ this.state.plankWidths[this.state.containerWidth] }
updateChildHeight={ this.receiveChildHeight.bind(this) }
handleLastPlank={ lastPlankHandler }
>
{ plank }
</Plank>
);
});
} else {
planks = this.props.children;
}
return (
<div style={ containerStyle } ref={ (c) => this._planksContainer = c }>{ planks }</div>
);
}
}
Planks.propTypes = { options: React.PropTypes.object };
Planks.defaultProps = {
options: {
'breakpoints': {
'544': 1,
'768': 2,
'992': 3,
'1200': 4
},
'horizontalPadding': 1,
'verticalPadding': 1,
'unitType': 'rem'
}
};
|