From dec9ed32484e6bd3c46408f5328224850a45b808 Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Mon, 1 Jan 2018 22:09:23 -0600 Subject: [PATCH] Introduce linear-partitioning into image layout It's not perfect but a good start. --- packages/gallery/package.json | 1 + packages/gallery/src/interface/allImages.js | 36 +++++---- .../src/interface/components/appbar.js | 7 +- packages/gallery/src/interface/focus.js | 14 +--- packages/gallery/src/interface/gallery.js | 26 +++---- .../gallery/src/interface/sectionPhoto.js | 23 ++++-- packages/gallery/src/interface/sectionView.js | 77 ++++++++++++++++--- packages/gallery/src/interface/styles.js | 3 +- packages/gallery/src/utils/domvm.js | 14 +++- 9 files changed, 143 insertions(+), 58 deletions(-) diff --git a/packages/gallery/package.json b/packages/gallery/package.json index 6a17bc1..8940bf9 100644 --- a/packages/gallery/package.json +++ b/packages/gallery/package.json @@ -18,6 +18,7 @@ "exif-parser": "~0.1.9", "extract-text-webpack-plugin": "^3.0.2", "frptools": "3.0.1", + "linear-partitioning": "0.3.2", "pica": "~2.0.8", "pouchdb-adapter-http": "~6.4.1", "pouchdb-adapter-idb": "~6.4.1", diff --git a/packages/gallery/src/interface/allImages.js b/packages/gallery/src/interface/allImages.js index 2bbf65c..51da313 100644 --- a/packages/gallery/src/interface/allImages.js +++ b/packages/gallery/src/interface/allImages.js @@ -15,7 +15,7 @@ import { pouchDocArrayHash, pouchDocHash, hashSet, extractID } from '../utils/co import { SectionView } from './sectionView.js'; import { Icon } from './components/icon.js'; import { injectStyle, styled } from '../services/style.js'; -import { CLICKABLE } from './styles.js'; +import { CLICKABLE, FILL_STYLE } from './styles.js'; export function uploadImages(evt, files) { Array.from(files || evt.currentTarget.files).forEach(ImageType.upload); @@ -198,19 +198,25 @@ export function AllImagesView(vm, params, key, context) { } return function() { - return scrollView( + return allImagesContainer( { class: 'allImages', - onclick: { - '.photoSelect .icon svg path': toggleSelect, - '.photoSelect .icon': toggleSelect, - '.sectionSelectButton .icon': toggleAll, - '.sectionSelectButton .icon svg path': toggleAll, - '.photoOverlay': photoClick - }, onscroll: handleContentScroll }, - sections().map(renderSection) + [ + allImagesContent( + { + onclick: { + '.photoSelect .icon svg path': toggleSelect, + '.photoSelect .icon': toggleSelect, + '.sectionSelectButton .icon': toggleAll, + '.sectionSelectButton .icon svg path': toggleAll, + '.photoOverlay': photoClick + } + }, + sections().map(renderSection) + ) + ] ); }; } @@ -229,6 +235,10 @@ const uploadButton = styled( CLICKABLE ); -const scrollView = styled({ - overflow: 'scroll' -}); +const allImagesContainer = styled( + { + overflow: 'scroll' + }, + FILL_STYLE +); +const allImagesContent = styled({}); diff --git a/packages/gallery/src/interface/components/appbar.js b/packages/gallery/src/interface/components/appbar.js index fdb2a3c..b050385 100644 --- a/packages/gallery/src/interface/components/appbar.js +++ b/packages/gallery/src/interface/components/appbar.js @@ -23,10 +23,9 @@ export function AppBarView(vm, params, key, opts) { ); const stateStyle = computed(pick('style', {}), [currentState]); - const boxShadowStyle = computed( - t => (t === 0 ? 'none' : `0px ${Math.min(t / 10, 3)}px 3px rgba(0, 0, 0, .2)`), - [companionScrollTop] - ); + const boxShadowStyle = computed(t => (t === 0 ? 'none' : `0px 3px 3px rgba(0, 0, 0, .2)`), [ + companionScrollTop + ]); const containerStyle = computed( (boxShadow, style) => ({ diff --git a/packages/gallery/src/interface/focus.js b/packages/gallery/src/interface/focus.js index 2fe7ff4..6c60c3d 100644 --- a/packages/gallery/src/interface/focus.js +++ b/packages/gallery/src/interface/focus.js @@ -4,6 +4,7 @@ import { subscribeToRender, defineView, nodeParentWithType, + viewportSize, defineElement as el } from '../utils/domvm.js'; @@ -21,13 +22,9 @@ export function FocusView(vm, params, key, { appbar }) { const id = prop(); const doc = prop(null, pouchDocHash); const { body } = document; - const windowSize = prop({}, o => (o ? `${o.width}x${o.height}` : '')); const nextLink = prop(); const prevLink = prop(); - const extractWindowSize = () => - windowSize({ width: window.innerWidth, height: window.innerHeight }); - const imageStyle = computed( ({ width: iw, height: ih }, { width: vw, height: vh }) => { const imageRatio = iw / ih; @@ -45,7 +42,7 @@ export function FocusView(vm, params, key, { appbar }) { }; } }, - [doc, windowSize] + [doc, viewportSize] ); function navBack() { @@ -83,18 +80,11 @@ export function FocusView(vm, params, key, { appbar }) { } }); - // Prime our window size - extractWindowSize(); - window.addEventListener('resize', extractWindowSize); - // Subscribe to our changables. subscribeToRender( vm, [doc, imageStyle, nextLink, prevLink], [ - // Keep up with the window resizing - () => window.removeEventListener('resize', extractWindowSize), - // Look for our image and set it. id.subscribe(async _id => { if (!_id) { diff --git a/packages/gallery/src/interface/gallery.js b/packages/gallery/src/interface/gallery.js index 5cca458..92f9576 100644 --- a/packages/gallery/src/interface/gallery.js +++ b/packages/gallery/src/interface/gallery.js @@ -18,6 +18,7 @@ import { AppBarView } from './components/appbar.js'; import { Icon } from './components/icon.js'; import { routeChanged } from '../services/router.js'; import { injectStyle, styled } from '../services/style.js'; +import { FILL_STYLE } from './styles.js'; export function GalleryView(vm) { const context = {}; @@ -31,10 +32,6 @@ export function GalleryView(vm) { vm.redraw(); }); - function handleContentScroll(evt) { - context.appbar.companionScrollTop(evt.target.scrollTop); - } - function renderMain() { return [ iv(appbar), @@ -63,7 +60,7 @@ export function GalleryView(vm) { } return function render() { - return el('.gallery', { class: fill }, [ + return container({ class: 'gallery' }, [ vw( Dropzone, { @@ -79,16 +76,19 @@ export function GalleryView(vm) { }; } -const FILL_STYLE = { - display: 'flex', - flex: 1, - flexDirection: 'column' -}; const fill = injectStyle(FILL_STYLE); - -const content = styled( +const container = styled( { - ['-webkit-transform']: 'translate3d(0,0,0);' // http://blog.getpostman.com/2015/01/23/ui-repaint-issue-on-chrome/ + overflow: 'hidden' }, FILL_STYLE ); + +const content = styled({ + position: 'absolute', + top: '58px', + bottom: 0, + left: 0, + right: 0, + ['-webkit-transform']: 'translate3d(0,0,0);' // http://blog.getpostman.com/2015/01/23/ui-repaint-issue-on-chrome/ +}); diff --git a/packages/gallery/src/interface/sectionPhoto.js b/packages/gallery/src/interface/sectionPhoto.js index 53838a1..2def088 100644 --- a/packages/gallery/src/interface/sectionPhoto.js +++ b/packages/gallery/src/interface/sectionPhoto.js @@ -9,7 +9,7 @@ import { } from '../utils/domvm.js'; import { router } from '../services/router.js'; import { injectStyle, styled } from '../services/style.js'; -import { DEFAULT_TRANSITION, CSS_FULL_SIZE, IMAGE_MARGIN, CLICKABLE } from './styles.js'; +import { DEFAULT_TRANSITION, FILL_STYLE, IMAGE_MARGIN, CLICKABLE } from './styles.js'; import { Icon } from './components/icon.js'; import { AttachmentImageView } from './components/attachmentImage.js'; @@ -22,7 +22,7 @@ export function SectionPhoto(vm, { doc }) { subscribeToRender(vm, [hover, hoverSelectButton]); - return function render(vm, { isSelected, selectMode }) { + return function render(vm, { isSelected, selectMode, width, height }) { return photoContainer( { href, @@ -32,6 +32,10 @@ export function SectionPhoto(vm, { doc }) { css: { cursor: selectMode ? CLICKABLE.cursor : 'zoom-in' }, + style: { + width, + height + }, _data: doc }, [ @@ -39,6 +43,10 @@ export function SectionPhoto(vm, { doc }) { src: doc.sizes.thumbnail || doc.sizes.full, css: { transform: isSelected ? 'translateZ(-50px)' : null + }, + style: { + width, + height } }), photoSelectButton( @@ -66,6 +74,10 @@ export function SectionPhoto(vm, { doc }) { css: { transform: isSelected ? 'translateZ(-50px)' : null, opacity: selectMode || hover() ? 0.7 : 0 + }, + style: { + width, + height } }) ] @@ -78,10 +90,11 @@ const photoContainer = styled('a', { perspective: '1000px', backgroundColor: '#eee', margin: `${IMAGE_MARGIN}px`, - cursor: 'zoom-in' + cursor: 'zoom-in', + display: 'inline-block' }); -const image = styled('img', CSS_FULL_SIZE, DEFAULT_TRANSITION, { +const image = styled('img', FILL_STYLE, DEFAULT_TRANSITION, { position: 'absolute', top: 0, left: 0, @@ -100,7 +113,7 @@ const photoSelectButton = styled(DEFAULT_TRANSITION, CLICKABLE, { opacity: 0 }); -const photoOverlay = styled(CSS_FULL_SIZE, DEFAULT_TRANSITION, { +const photoOverlay = styled(FILL_STYLE, DEFAULT_TRANSITION, { position: 'absolute', // Unnecessary but helps with a rendering bug in Chrome. https://gitlab.com/explorigin/gallery/issues/1 top: '0px', left: '0px', diff --git a/packages/gallery/src/interface/sectionView.js b/packages/gallery/src/interface/sectionView.js index e3b363b..3c7c209 100644 --- a/packages/gallery/src/interface/sectionView.js +++ b/packages/gallery/src/interface/sectionView.js @@ -1,36 +1,88 @@ +import partition from 'linear-partitioning'; + import { defineView as vw, defineElement as el, patchRefStyle, - patchNodeStyle + patchNodeStyle, + subscribeToRender, + viewportSize } from '../utils/domvm.js'; import { injectStyle, styled } from '../services/style.js'; -import { DEFAULT_TRANSITION, CLICKABLE } from './styles.js'; +import { DEFAULT_TRANSITION, CLICKABLE, IMAGE_MARGIN, CONTENT_MARGIN } from './styles.js'; import { Icon } from './components/icon.js'; import { SectionPhoto } from './sectionPhoto.js'; import { extractID } from '../utils/conversion.js'; +const OPTIMAL_IMAGE_HEIGHT = 140; +const ROW_HEIGHT_CUTOFF_MODIFIER = 2; + +const IMAGE_MARGIN_WIDTH = 2 * IMAGE_MARGIN; + +const aspectRatio = (img, margin = 0) => (img.width + margin) / (img.height + margin); + export function SectionView(vm, params, key, context) { const { appbar } = context; const { title, photos } = params; const sectionSelectButtonRef = `secSel${key}`; + function calculateSections(photos) { + const { width: vw } = viewportSize(); + const availableWidth = vw - CONTENT_MARGIN; + const totalImageRatio = photos.reduce( + (acc, img) => acc + aspectRatio(img, IMAGE_MARGIN_WIDTH), + 0 + ); + const rowCount = Math.ceil(totalImageRatio * OPTIMAL_IMAGE_HEIGHT / availableWidth); + const rowRatios = partition(photos.map(aspectRatio), rowCount); + + let index = 0; + + const result = rowRatios.map(row => { + const rowTotal = row.reduce((acc, r) => acc + r, 0); + const imageRatio = row[0]; + const portion = imageRatio / rowTotal; + let rowHeight = availableWidth * portion / aspectRatio(photos[index]); + if (rowHeight > OPTIMAL_IMAGE_HEIGHT * ROW_HEIGHT_CUTOFF_MODIFIER) { + rowHeight = OPTIMAL_IMAGE_HEIGHT * ROW_HEIGHT_CUTOFF_MODIFIER; + } + + const rowResult = row.map((imageRatio, imgIndex) => ({ + photo: photos[imgIndex + index], + width: imageRatio * rowHeight - IMAGE_MARGIN_WIDTH, + height: rowHeight + })); + + index += row.length; + return rowResult; + }); + return result; + } + + subscribeToRender(vm, [viewportSize]); + return function render(vm, params) { const { selectedIds, selectMode } = params; - function photoTemplate(doc) { + function photoTemplate({ photo, width, height }) { return vw( SectionPhoto, { - doc, - isSelected: selectedIds.has(doc._id), - selectMode + doc: photo, + isSelected: selectedIds.has(photo._id), + selectMode, + width, + height }, - doc._hash(), + photo._hash(), context ); } + function sectionRowTemplate(photos) { + return sectionRow(photos.map(photoTemplate)); + } + return sectionContainer( { class: 'section', @@ -57,14 +109,15 @@ export function SectionView(vm, params, key, context) { [Icon({ name: 'check_circle', size: 0.25 })] ) ]), - sectionContent(photos.map(photoTemplate)) + sectionContent(calculateSections(photos).map(sectionRowTemplate)) ] ); }; } const sectionContainer = styled({ - margin: '10px' + margin: `${CONTENT_MARGIN}px`, + flexDirection: 'column' }); const sectionTitle = styled({ @@ -75,6 +128,12 @@ const sectionTitle = styled({ const sectionContent = styled({ display: 'flex', alignItems: 'flex-start', + flexDirection: 'column' +}); + +const sectionRow = styled({ + flexDirection: 'row', + flex: 1, userSelect: 'none' }); diff --git a/packages/gallery/src/interface/styles.js b/packages/gallery/src/interface/styles.js index d556e79..69ba664 100644 --- a/packages/gallery/src/interface/styles.js +++ b/packages/gallery/src/interface/styles.js @@ -1,6 +1,7 @@ export const IMAGE_MARGIN = 2; +export const CONTENT_MARGIN = 10; -export const CSS_FULL_SIZE = { +export const FILL_STYLE = { width: '100%', height: '100%' }; diff --git a/packages/gallery/src/utils/domvm.js b/packages/gallery/src/utils/domvm.js index 6b71546..5b1709c 100644 --- a/packages/gallery/src/utils/domvm.js +++ b/packages/gallery/src/utils/domvm.js @@ -1,7 +1,7 @@ // export * from 'domvm/dist/dev/domvm.dev.js'; export * from 'domvm/dist/mini/domvm.mini.js'; import { defineView } from 'domvm/dist/mini/domvm.mini.js'; -import { call } from 'frptools'; +import { prop, call } from 'frptools'; import { deepAssign } from './conversion.js'; import { error } from '../services/console.js'; @@ -50,3 +50,15 @@ export function renderSwitch(renderMap, switchValue) { const params = renderMap[switchValue]; return params ? defineView.apply(null, params) : `VIEW ${switchValue} NOT FOUND`; } + +// Expose viewport size in a subscribable. +const SCROLLBAR_SIZE = 20; +export const viewportSize = prop({}, o => (o ? `${o.width}x${o.height}` : '')); +const extractWindowSize = () => + viewportSize({ + width: window.innerWidth - SCROLLBAR_SIZE, + height: window.innerHeight - SCROLLBAR_SIZE + }); +window.addEventListener('resize', extractWindowSize); +// Prime our window size +extractWindowSize();