summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/jetpack/extensions/blocks/tiled-gallery/layout')
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js3
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js7
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js160
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js104
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js280
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js107
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap98
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap30
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js16
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js21
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js11
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js8
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js33
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js103
14 files changed, 981 insertions, 0 deletions
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js
new file mode 100644
index 00000000..a3ed5cdf
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js
@@ -0,0 +1,3 @@
+export default function Column( { children } ) {
+ return <div className="tiled-gallery__col">{ children }</div>;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js
new file mode 100644
index 00000000..94fc61e4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js
@@ -0,0 +1,7 @@
+export default function Gallery( { children, galleryRef } ) {
+ return (
+ <div className="tiled-gallery__gallery" ref={ galleryRef }>
+ { children }
+ </div>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js
new file mode 100644
index 00000000..abcb5641
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js
@@ -0,0 +1,160 @@
+/**
+ * External dependencies
+ */
+import photon from 'photon';
+import { __, sprintf } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { format as formatUrl, parse as parseUrl } from 'url';
+import { isBlobURL } from '@wordpress/blob';
+
+/**
+ * Internal dependencies
+ */
+import GalleryImageEdit from '../gallery-image/edit';
+import GalleryImageSave from '../gallery-image/save';
+import Mosaic from './mosaic';
+import Square from './square';
+import { PHOTON_MAX_RESIZE } from '../constants';
+
+export default class Layout extends Component {
+ photonize( { height, width, url } ) {
+ if ( ! url ) {
+ return;
+ }
+
+ // Do not Photonize images that are still uploading or from localhost
+ if ( isBlobURL( url ) || /^https?:\/\/localhost/.test( url ) ) {
+ return url;
+ }
+
+ // Drop query args, photon URLs can't handle them
+ // This should be the "raw" url, we'll add dimensions later
+ const cleanUrl = url.split( '?', 1 )[ 0 ];
+
+ const photonImplementation = isWpcomFilesUrl( url ) ? photonWpcomImage : photon;
+
+ const { layoutStyle } = this.props;
+
+ if ( isSquareishLayout( layoutStyle ) && width && height ) {
+ const size = Math.min( PHOTON_MAX_RESIZE, width, height );
+ return photonImplementation( cleanUrl, { resize: `${ size },${ size }` } );
+ }
+ return photonImplementation( cleanUrl );
+ }
+
+ // This is tricky:
+ // - We need to "photonize" to resize the images at appropriate dimensions
+ // - The resize will depend on the image size and the layout in some cases
+ // - Handlers need to be created by index so that the image changes can be applied correctly.
+ // This is because the images are stored in an array in the block attributes.
+ renderImage( img, i ) {
+ const {
+ imageFilter,
+ images,
+ isSave,
+ linkTo,
+ onRemoveImage,
+ onSelectImage,
+ selectedImage,
+ setImageAttributes,
+ } = this.props;
+
+ /* translators: %1$d is the order number of the image, %2$d is the total number of images. */
+ const ariaLabel = sprintf(
+ __( 'image %1$d of %2$d in gallery', 'jetpack' ),
+ i + 1,
+ images.length
+ );
+ const Image = isSave ? GalleryImageSave : GalleryImageEdit;
+
+ return (
+ <Image
+ alt={ img.alt }
+ aria-label={ ariaLabel }
+ height={ img.height }
+ id={ img.id }
+ imageFilter={ imageFilter }
+ isSelected={ selectedImage === i }
+ key={ i }
+ link={ img.link }
+ linkTo={ linkTo }
+ onRemove={ isSave ? undefined : onRemoveImage( i ) }
+ onSelect={ isSave ? undefined : onSelectImage( i ) }
+ origUrl={ img.url }
+ setAttributes={ isSave ? undefined : setImageAttributes( i ) }
+ url={ this.photonize( img ) }
+ width={ img.width }
+ />
+ );
+ }
+
+ render() {
+ const { align, children, className, columns, images, layoutStyle } = this.props;
+
+ const LayoutRenderer = isSquareishLayout( layoutStyle ) ? Square : Mosaic;
+
+ const renderedImages = this.props.images.map( this.renderImage, this );
+
+ return (
+ <div className={ className }>
+ <LayoutRenderer
+ align={ align }
+ columns={ columns }
+ images={ images }
+ layoutStyle={ layoutStyle }
+ renderedImages={ renderedImages }
+ />
+ { children }
+ </div>
+ );
+ }
+}
+
+function isSquareishLayout( layout ) {
+ return [ 'circle', 'square' ].includes( layout );
+}
+
+function isWpcomFilesUrl( url ) {
+ const { host } = parseUrl( url );
+ return /\.files\.wordpress\.com$/.test( host );
+}
+
+/**
+ * Apply photon arguments to *.files.wordpress.com images
+ *
+ * This function largely duplicates the functionlity of the photon.js lib.
+ * This is necessary because we want to serve images from *.files.wordpress.com so that private
+ * WordPress.com sites can use this block which depends on a Photon-like image service.
+ *
+ * If we pass all images through Photon servers, some images are unreachable. *.files.wordpress.com
+ * is already photon-like so we can pass it the same parameters for image resizing.
+ *
+ * @param {string} url Image url
+ * @param {Object} opts Options to pass to photon
+ *
+ * @return {string} Url string with options applied
+ */
+function photonWpcomImage( url, opts = {} ) {
+ // Adhere to the same options API as the photon.js lib
+ const photonLibMappings = {
+ width: 'w',
+ height: 'h',
+ letterboxing: 'lb',
+ removeLetterboxing: 'ulb',
+ };
+
+ // Discard some param parts
+ const { auth, hash, port, query, search, ...urlParts } = parseUrl( url );
+
+ // Build query
+ // This reduction intentionally mutates the query as it is built internally.
+ urlParts.query = Object.keys( opts ).reduce(
+ ( q, key ) =>
+ Object.assign( q, {
+ [ photonLibMappings.hasOwnProperty( key ) ? photonLibMappings[ key ] : key ]: opts[ key ],
+ } ),
+ {}
+ );
+
+ return formatUrl( urlParts );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js
new file mode 100644
index 00000000..8c56b164
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js
@@ -0,0 +1,104 @@
+/**
+ * External dependencies
+ */
+import { Component, createRef } from '@wordpress/element';
+import ResizeObserver from 'resize-observer-polyfill';
+
+/**
+ * Internal dependencies
+ */
+import Column from '../column';
+import Gallery from '../gallery';
+import Row from '../row';
+import { getGalleryRows, handleRowResize } from './resize';
+import { imagesToRatios, ratiosToColumns, ratiosToMosaicRows } from './ratios';
+
+export default class Mosaic extends Component {
+ gallery = createRef();
+ pendingRaf = null;
+ ro = null; // resizeObserver instance
+
+ componentDidMount() {
+ this.observeResize();
+ }
+
+ componentWillUnmount() {
+ this.unobserveResize();
+ }
+
+ componentDidUpdate( prevProps ) {
+ if ( prevProps.images !== this.props.images || prevProps.align !== this.props.align ) {
+ this.triggerResize();
+ } else if ( 'columns' === this.props.layoutStyle && prevProps.columns !== this.props.columns ) {
+ this.triggerResize();
+ }
+ }
+
+ handleGalleryResize = entries => {
+ if ( this.pendingRaf ) {
+ cancelAnimationFrame( this.pendingRaf );
+ this.pendingRaf = null;
+ }
+ this.pendingRaf = requestAnimationFrame( () => {
+ for ( const { contentRect, target } of entries ) {
+ const { width } = contentRect;
+ getGalleryRows( target ).forEach( row => handleRowResize( row, width ) );
+ }
+ } );
+ };
+
+ triggerResize() {
+ if ( this.gallery.current ) {
+ this.handleGalleryResize( [
+ {
+ target: this.gallery.current,
+ contentRect: { width: this.gallery.current.clientWidth },
+ },
+ ] );
+ }
+ }
+
+ observeResize() {
+ this.triggerResize();
+ this.ro = new ResizeObserver( this.handleGalleryResize );
+ if ( this.gallery.current ) {
+ this.ro.observe( this.gallery.current );
+ }
+ }
+
+ unobserveResize() {
+ if ( this.ro ) {
+ this.ro.disconnect();
+ this.ro = null;
+ }
+ if ( this.pendingRaf ) {
+ cancelAnimationFrame( this.pendingRaf );
+ this.pendingRaf = null;
+ }
+ }
+
+ render() {
+ const { align, columns, images, layoutStyle, renderedImages } = this.props;
+
+ const ratios = imagesToRatios( images );
+ const rows =
+ 'columns' === layoutStyle
+ ? ratiosToColumns( ratios, columns )
+ : ratiosToMosaicRows( ratios, { isWide: [ 'full', 'wide' ].includes( align ) } );
+
+ let cursor = 0;
+ return (
+ <Gallery galleryRef={ this.gallery }>
+ { rows.map( ( row, rowIndex ) => (
+ <Row key={ rowIndex }>
+ { row.map( ( colSize, colIndex ) => {
+ const columnImages = renderedImages.slice( cursor, cursor + colSize );
+ cursor += colSize;
+ return <Column key={ colIndex }>{ columnImages }</Column>;
+ } ) }
+ </Row>
+ ) ) }
+ </Gallery>
+ );
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js
new file mode 100644
index 00000000..8accd552
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js
@@ -0,0 +1,280 @@
+/**
+ * External dependencies
+ */
+import {
+ drop,
+ every,
+ isEqual,
+ map,
+ overEvery,
+ some,
+ sum,
+ take,
+ takeRight,
+ takeWhile,
+ zipWith,
+} from 'lodash';
+
+export function imagesToRatios( images ) {
+ return map( images, ratioFromImage );
+}
+
+export function ratioFromImage( { height, width } ) {
+ return height && width ? width / height : 1;
+}
+
+/**
+ * Build three columns, each of which should contain approximately 1/3 of the total ratio
+ *
+ * @param {Array.<number>} ratios Ratios of images put into shape
+ * @param {number} columnCount Number of columns
+ *
+ * @return {Array.<Array.<number>>} Shape of rows and columns
+ */
+export function ratiosToColumns( ratios, columnCount ) {
+ // If we don't have more than 1 per column, just return a simple 1 ratio per column shape
+ if ( ratios.length <= columnCount ) {
+ return [ Array( ratios.length ).fill( 1 ) ];
+ }
+
+ const total = sum( ratios );
+ const targetColRatio = total / columnCount;
+
+ const row = [];
+ let toProcess = ratios;
+ let accumulatedRatio = 0;
+
+ // We skip the last column in the loop and add rest later
+ for ( let i = 0; i < columnCount - 1; i++ ) {
+ const colSize = takeWhile( toProcess, ratio => {
+ const shouldTake = accumulatedRatio <= ( i + 1 ) * targetColRatio;
+ if ( shouldTake ) {
+ accumulatedRatio += ratio;
+ }
+ return shouldTake;
+ } ).length;
+ row.push( colSize );
+ toProcess = drop( toProcess, colSize );
+ }
+
+ // Don't calculate last column, just add what's left
+ row.push( toProcess.length );
+
+ // A shape is an array of rows. Wrap our row in an array.
+ return [ row ];
+}
+
+/**
+ * These are partially applied functions.
+ * They rely on helper function (defined below) to create a function that expects to be passed ratios
+ * during processing.
+ *
+ * …FitsNextImages() functions should be passed ratios to be processed
+ * …IsNotRecent() functions should be passed the processed shapes
+ */
+
+const reverseSymmetricRowIsNotRecent = isNotRecentShape( [ 2, 1, 2 ], 5 );
+const reverseSymmetricFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isPortrait,
+ isLandscape,
+ isLandscape,
+] );
+const longSymmetricRowFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isLandscape,
+] );
+const longSymmetricRowIsNotRecent = isNotRecentShape( [ 3, 1, 3 ], 5 );
+const symmetricRowFitsNextImages = checkNextRatios( [
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+] );
+const symmetricRowIsNotRecent = isNotRecentShape( [ 1, 2, 1 ], 5 );
+const oneThreeFitsNextImages = checkNextRatios( [
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isLandscape,
+] );
+const oneThreeIsNotRecent = isNotRecentShape( [ 1, 3 ], 3 );
+const threeOneIsFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+] );
+const threeOneIsNotRecent = isNotRecentShape( [ 3, 1 ], 3 );
+const oneTwoFitsNextImages = checkNextRatios( [
+ lt( 1.6 ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+] );
+const oneTwoIsNotRecent = isNotRecentShape( [ 1, 2 ], 3 );
+const fiveIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1, 1 ], 1 );
+const fourIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1 ], 1 );
+const threeIsNotRecent = isNotRecentShape( [ 1, 1, 1 ], 3 );
+const twoOneFitsNextImages = checkNextRatios( [
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ lt( 1.6 ),
+] );
+const twoOneIsNotRecent = isNotRecentShape( [ 2, 1 ], 3 );
+const panoramicFitsNextImages = checkNextRatios( [ isPanoramic ] );
+
+export function ratiosToMosaicRows( ratios, { isWide } = {} ) {
+ // This function will recursively process the input until it is consumed
+ const go = ( processed, toProcess ) => {
+ if ( ! toProcess.length ) {
+ return processed;
+ }
+
+ let next;
+
+ if (
+ /* Reverse_Symmetric_Row */
+ toProcess.length > 15 &&
+ reverseSymmetricFitsNextImages( toProcess ) &&
+ reverseSymmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 2, 1, 2 ];
+ } else if (
+ /* Long_Symmetric_Row */
+ toProcess.length > 15 &&
+ longSymmetricRowFitsNextImages( toProcess ) &&
+ longSymmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 3, 1, 3 ];
+ } else if (
+ /* Symmetric_Row */
+ toProcess.length !== 5 &&
+ symmetricRowFitsNextImages( toProcess ) &&
+ symmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 1, 2, 1 ];
+ } else if (
+ /* One_Three */
+ oneThreeFitsNextImages( toProcess ) &&
+ oneThreeIsNotRecent( processed )
+ ) {
+ next = [ 1, 3 ];
+ } else if (
+ /* Three_One */
+ threeOneIsFitsNextImages( toProcess ) &&
+ threeOneIsNotRecent( processed )
+ ) {
+ next = [ 3, 1 ];
+ } else if (
+ /* One_Two */
+ oneTwoFitsNextImages( toProcess ) &&
+ oneTwoIsNotRecent( processed )
+ ) {
+ next = [ 1, 2 ];
+ } else if (
+ /* Five */
+ isWide &&
+ ( toProcess.length === 5 || ( toProcess.length !== 10 && toProcess.length > 6 ) ) &&
+ fiveIsNotRecent( processed ) &&
+ sum( take( toProcess, 5 ) ) < 5
+ ) {
+ next = [ 1, 1, 1, 1, 1 ];
+ } else if (
+ /* Four */
+ isFourValidCandidate( processed, toProcess )
+ ) {
+ next = [ 1, 1, 1, 1 ];
+ } else if (
+ /* Three */
+ isThreeValidCandidate( processed, toProcess, isWide )
+ ) {
+ next = [ 1, 1, 1 ];
+ } else if (
+ /* Two_One */
+ twoOneFitsNextImages( toProcess ) &&
+ twoOneIsNotRecent( processed )
+ ) {
+ next = [ 2, 1 ];
+ } else if ( /* Panoramic */ panoramicFitsNextImages( toProcess ) ) {
+ next = [ 1 ];
+ } else if ( /* One_One */ toProcess.length > 3 ) {
+ next = [ 1, 1 ];
+ } else {
+ // Everything left
+ next = Array( toProcess.length ).fill( 1 );
+ }
+
+ // Add row
+ const nextProcessed = processed.concat( [ next ] );
+
+ // Trim consumed images from next processing step
+ const consumedImages = sum( next );
+ const nextToProcess = toProcess.slice( consumedImages );
+
+ return go( nextProcessed, nextToProcess );
+ };
+ return go( [], ratios );
+}
+
+function isThreeValidCandidate( processed, toProcess, isWide ) {
+ const ratio = sum( take( toProcess, 3 ) );
+ return (
+ toProcess.length >= 3 &&
+ toProcess.length !== 4 &&
+ toProcess.length !== 6 &&
+ threeIsNotRecent( processed ) &&
+ ( ratio < 2.5 ||
+ ( ratio < 5 &&
+ /* nextAreSymettric */
+ ( toProcess.length >= 3 &&
+ /* @FIXME floating point equality?? */ toProcess[ 0 ] === toProcess[ 2 ] ) ) ||
+ isWide )
+ );
+}
+
+function isFourValidCandidate( processed, toProcess ) {
+ const ratio = sum( take( toProcess, 4 ) );
+ return (
+ ( fourIsNotRecent( processed ) && ( ratio < 3.5 && toProcess.length > 5 ) ) ||
+ ( ratio < 7 && toProcess.length === 4 )
+ );
+}
+
+function isNotRecentShape( shape, numRecents ) {
+ return recents =>
+ ! some( takeRight( recents, numRecents ), recentShape => isEqual( recentShape, shape ) );
+}
+
+function checkNextRatios( shape ) {
+ return ratios =>
+ ratios.length >= shape.length &&
+ every( zipWith( shape, ratios.slice( 0, shape.length ), ( f, r ) => f( r ) ) );
+}
+
+function isLandscape( ratio ) {
+ return ratio >= 1 && ratio < 2;
+}
+
+function isPortrait( ratio ) {
+ return ratio < 1;
+}
+
+function isPanoramic( ratio ) {
+ return ratio >= 2;
+}
+
+// >=
+function gte( n ) {
+ return m => m >= n;
+}
+
+// <
+function lt( n ) {
+ return m => m < n;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js
new file mode 100644
index 00000000..022729c8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js
@@ -0,0 +1,107 @@
+/**
+ * Internal dependencies
+ */
+import { GUTTER_WIDTH } from '../../constants';
+
+/**
+ * Distribute a difference across ns so that their sum matches the target
+ *
+ * @param {Array<number>} parts Array of numbers to fit
+ * @param {number} target Number that sum should match
+ * @return {Array<number>} Adjusted parts
+ */
+function adjustFit( parts, target ) {
+ const diff = target - parts.reduce( ( sum, n ) => sum + n, 0 );
+ const partialDiff = diff / parts.length;
+ return parts.map( p => p + partialDiff );
+}
+
+export function handleRowResize( row, width ) {
+ applyRowRatio( row, getRowRatio( row ), width );
+}
+
+function getRowRatio( row ) {
+ const result = getRowCols( row )
+ .map( getColumnRatio )
+ .reduce(
+ ( [ ratioA, weightedRatioA ], [ ratioB, weightedRatioB ] ) => {
+ return [ ratioA + ratioB, weightedRatioA + weightedRatioB ];
+ },
+ [ 0, 0 ]
+ );
+ return result;
+}
+
+export function getGalleryRows( gallery ) {
+ return Array.from( gallery.querySelectorAll( '.tiled-gallery__row' ) );
+}
+
+function getRowCols( row ) {
+ return Array.from( row.querySelectorAll( '.tiled-gallery__col' ) );
+}
+
+function getColImgs( col ) {
+ return Array.from(
+ col.querySelectorAll( '.tiled-gallery__item > img, .tiled-gallery__item > a > img' )
+ );
+}
+
+function getColumnRatio( col ) {
+ const imgs = getColImgs( col );
+ const imgCount = imgs.length;
+ const ratio =
+ 1 /
+ imgs.map( getImageRatio ).reduce( ( partialColRatio, imgRatio ) => {
+ return partialColRatio + 1 / imgRatio;
+ }, 0 );
+ const result = [ ratio, ratio * imgCount || 1 ];
+ return result;
+}
+
+function getImageRatio( img ) {
+ const w = parseInt( img.dataset.width, 10 );
+ const h = parseInt( img.dataset.height, 10 );
+ const result = w && ! Number.isNaN( w ) && h && ! Number.isNaN( h ) ? w / h : 1;
+ return result;
+}
+
+function applyRowRatio( row, [ ratio, weightedRatio ], width ) {
+ const rawHeight =
+ ( 1 / ratio ) * ( width - GUTTER_WIDTH * ( row.childElementCount - 1 ) - weightedRatio );
+
+ applyColRatio( row, {
+ rawHeight,
+ rowWidth: width - GUTTER_WIDTH * ( row.childElementCount - 1 ),
+ } );
+}
+
+function applyColRatio( row, { rawHeight, rowWidth } ) {
+ const cols = getRowCols( row );
+
+ const colWidths = cols.map(
+ col => ( rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ) ) * getColumnRatio( col )[ 0 ]
+ );
+
+ const adjustedWidths = adjustFit( colWidths, rowWidth );
+
+ cols.forEach( ( col, i ) => {
+ const rawWidth = colWidths[ i ];
+ const width = adjustedWidths[ i ];
+ applyImgRatio( col, {
+ colHeight: rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ),
+ width,
+ rawWidth,
+ } );
+ } );
+}
+
+function applyImgRatio( col, { colHeight, width, rawWidth } ) {
+ const imgHeights = getColImgs( col ).map( img => rawWidth / getImageRatio( img ) );
+ const adjustedHeights = adjustFit( imgHeights, colHeight );
+
+ // Set size of col children, not the <img /> element
+ Array.from( col.children ).forEach( ( item, i ) => {
+ const height = adjustedHeights[ i ];
+ item.setAttribute( 'style', `height:${ height }px;width:${ width }px;` );
+ } );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap
new file mode 100644
index 00000000..e726fa52
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap
@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders as expected 1`] = `
+<Gallery
+ galleryRef={
+ Object {
+ "current": null,
+ }
+ }
+>
+ <Row
+ key="0"
+ >
+ <Column
+ key="0"
+ >
+ 0
+ </Column>
+ </Row>
+ <Row
+ key="1"
+ >
+ <Column
+ key="0"
+ >
+ 1
+ </Column>
+ </Row>
+ <Row
+ key="2"
+ >
+ <Column
+ key="0"
+ >
+ 2
+ </Column>
+ <Column
+ key="1"
+ >
+ 3
+ </Column>
+ <Column
+ key="2"
+ >
+ 4
+ </Column>
+ <Column
+ key="3"
+ >
+ 5
+ </Column>
+ </Row>
+ <Row
+ key="3"
+ >
+ <Column
+ key="0"
+ >
+ 6
+ </Column>
+ <Column
+ key="1"
+ >
+ 7
+ </Column>
+ </Row>
+ <Row
+ key="4"
+ >
+ <Column
+ key="0"
+ >
+ 8
+ </Column>
+ <Column
+ key="1"
+ >
+ 9
+ 10
+ </Column>
+ </Row>
+ <Row
+ key="5"
+ >
+ <Column
+ key="0"
+ >
+ 11
+ 12
+ </Column>
+ <Column
+ key="1"
+ >
+ 13
+ </Column>
+ </Row>
+</Gallery>
+`;
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap
new file mode 100644
index 00000000..df02118c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap
@@ -0,0 +1,30 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ratiosToMosaicRows transforms as expected 1`] = `
+Array [
+ Array [
+ 1,
+ ],
+ Array [
+ 1,
+ ],
+ Array [
+ 1,
+ 1,
+ 1,
+ 1,
+ ],
+ Array [
+ 1,
+ 1,
+ ],
+ Array [
+ 1,
+ 2,
+ ],
+ Array [
+ 2,
+ 1,
+ ],
+]
+`;
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js
new file mode 100644
index 00000000..77db288c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js
@@ -0,0 +1,16 @@
+export const ratios = [
+ 4,
+ 2.26056338028169,
+ 0.6676143094053542,
+ 0.75,
+ 0.7444409646100846,
+ 0.6666666666666666,
+ 0.8000588062334607,
+ 3.6392174704276616,
+ 1.335559265442404,
+ 1.509433962264151,
+ 1.6,
+ 1.3208430913348945,
+ 1.3553937789543349,
+ 1.499531396438613,
+];
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js
new file mode 100644
index 00000000..72e49ba6
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js
@@ -0,0 +1,21 @@
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { range } from 'lodash';
+import { shallow } from 'enzyme';
+
+/**
+ * Internal dependencies
+ */
+import Mosaic from '..';
+import * as imageSets from '../../test/fixtures/image-sets';
+
+test( 'renders as expected', () => {
+ Object.keys( imageSets ).forEach( k => {
+ const images = imageSets[ k ];
+ expect(
+ shallow( <Mosaic images={ images } renderedImages={ range( images.length ) } /> )
+ ).toMatchSnapshot();
+ } );
+} );
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js
new file mode 100644
index 00000000..3756b971
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js
@@ -0,0 +1,11 @@
+/**
+ * Internal dependencies
+ */
+import { ratiosToMosaicRows } from '../ratios';
+import { ratios } from './fixtures/ratios';
+
+describe( 'ratiosToMosaicRows', () => {
+ test( 'transforms as expected', () => {
+ expect( ratiosToMosaicRows( ratios ) ).toMatchSnapshot();
+ } );
+} );
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js
new file mode 100644
index 00000000..200a58c2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js
@@ -0,0 +1,8 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+export default function Row( { children, className } ) {
+ return <div className={ classnames( 'tiled-gallery__row', className ) }>{ children }</div>;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js
new file mode 100644
index 00000000..2a1ab888
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js
@@ -0,0 +1,33 @@
+/**
+ * External dependencies
+ */
+import { chunk, drop, take } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import Row from './row';
+import Column from './column';
+import Gallery from './gallery';
+import { MAX_COLUMNS } from '../constants';
+
+export default function Square( { columns, renderedImages } ) {
+ const columnCount = Math.min( MAX_COLUMNS, columns );
+
+ const remainder = renderedImages.length % columnCount;
+
+ return (
+ <Gallery>
+ { [
+ ...( remainder ? [ take( renderedImages, remainder ) ] : [] ),
+ ...chunk( drop( renderedImages, remainder ), columnCount ),
+ ].map( ( imagesInRow, rowIndex ) => (
+ <Row key={ rowIndex } className={ `columns-${ imagesInRow.length }` }>
+ { imagesInRow.map( ( image, colIndex ) => (
+ <Column key={ colIndex }>{ image }</Column>
+ ) ) }
+ </Row>
+ ) ) }
+ </Gallery>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js
new file mode 100644
index 00000000..fd477f5a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js
@@ -0,0 +1,103 @@
+export const imageSet1 = [
+ {
+ alt: '',
+ id: 163,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-bay-bridge-356830.jpg',
+ height: 2048,
+ width: 8192,
+ },
+ {
+ alt: '',
+ id: 162,
+ url: 'https://example.files.wordpress.com/2018/12/bloom-blossom-flora-40797-1.jpg',
+ height: 1562,
+ width: 3531,
+ },
+ {
+ alt: '',
+ id: 161,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-building-city-597049.jpg',
+ height: 4221,
+ width: 2818,
+ },
+ {
+ alt: '',
+ id: 160,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-art-blue-699466.jpg',
+ height: 4032,
+ width: 3024,
+ },
+ {
+ alt: '',
+ id: 159,
+ url:
+ 'https://example.files.wordpress.com/2018/12/black-and-white-construction-ladder-54335.jpg',
+ height: 3193,
+ width: 2377,
+ },
+ {
+ alt: '',
+ id: 158,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-buildings-city-1672110.jpg',
+ height: 6000,
+ width: 4000,
+ },
+ {
+ alt: '',
+ id: 157,
+ url:
+ 'https://example.files.wordpress.com/2018/12/architectural-design-architecture-black-and-white-1672122-1.jpg',
+ height: 3401,
+ width: 2721,
+ },
+ {
+ alt: '',
+ id: 156,
+ url: 'https://example.files.wordpress.com/2018/12/grass-hd-wallpaper-lake-127753.jpg',
+ height: 2198,
+ width: 7999,
+ },
+ {
+ alt: '',
+ id: 122,
+ url: 'https://example.files.wordpress.com/2018/12/texaco-car-1.jpg',
+ height: 599,
+ width: 800,
+ },
+ {
+ alt: '',
+ id: 92,
+ url: 'https://example.files.wordpress.com/2018/12/43824553435_ea38cbc92a_m.jpg',
+ height: 159,
+ width: 240,
+ },
+ {
+ alt: '',
+ id: 90,
+ url: 'https://example.files.wordpress.com/2018/12/42924685680_7b5632e58e_m.jpg',
+ height: 150,
+ width: 240,
+ },
+ {
+ alt: '',
+ id: 89,
+ url:
+ 'https://example.files.wordpress.com/2018/12/31962299833_1e106f7f7a_z-1-e1545262352979.jpg',
+ height: 427,
+ width: 564,
+ },
+ {
+ alt: '',
+ id: 88,
+ url: 'https://example.files.wordpress.com/2018/12/29797558147_3c72afa8f4_k.jpg',
+ height: 1511,
+ width: 2048,
+ },
+ {
+ alt: '',
+ id: 8,
+ url: 'https://example.files.wordpress.com/2018/11/person-smartphone-office-table.jpeg',
+ height: 1067,
+ width: 1600,
+ },
+];