Merge pull request #2507 from matrix-org/bwindels/roomlistjslayout

Redesign: new layout algorithm for room sublists.
This commit is contained in:
Bruno Windels 2019-01-28 14:55:31 +00:00 committed by GitHub
commit 55e0838c82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 379 additions and 73 deletions

View file

@ -32,34 +32,13 @@ limitations under the License.
*/ */
.mx_RoomSubList { .mx_RoomSubList {
min-height: 31px;
flex: 0 10000 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.mx_RoomSubList.resized-sized {
/*
flex-basis to 0 so sublists
are not shrinking/growing relative
to their content (as would be the case with auto),
as this intervenes with sizing an item exactly
when not available space is available
in the flex container
*/
flex: 1 1 0;
}
.mx_RoomSubList_nonEmpty { .mx_RoomSubList_nonEmpty .mx_AutoHideScrollbar_offset {
min-height: 74px; padding-bottom: 4px;
.mx_AutoHideScrollbar_offset {
padding-bottom: 4px;
}
}
.mx_RoomSubList_hidden {
flex: none !important;
} }
.mx_RoomSubList_labelContainer { .mx_RoomSubList_labelContainer {

View file

@ -17,13 +17,14 @@ limitations under the License.
.mx_RoomList { .mx_RoomList {
/* take up remaining space below TopLeftMenu */ /* take up remaining space below TopLeftMenu */
flex: 1 1 auto; flex: 1;
/* use flexbox to layout sublists */
display: flex;
flex-direction: column;
min-height: 0; min-height: 0;
} }
.mx_RoomList .mx_ResizeHandle {
position: relative;
}
.mx_SearchBox { .mx_SearchBox {
flex: none; flex: none;
} }

View file

@ -212,6 +212,7 @@ const LeftPanel = React.createClass({
<CallPreview ConferenceHandler={VectorConferenceHandler} /> <CallPreview ConferenceHandler={VectorConferenceHandler} />
<RoomList <RoomList
ref={this.collectRoomList} ref={this.collectRoomList}
toolbarShown={this.props.toolbarShown}
collapsed={this.props.collapsed} collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter} searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} /> ConferenceHandler={VectorConferenceHandler} />

View file

@ -545,6 +545,7 @@ const LoggedInView = React.createClass({
<DragDropContext onDragEnd={this._onDragEnd}> <DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}> <div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel <LeftPanel
toolbarShown={!!topBar}
collapsed={this.props.collapseLhs || this.state.collapseLhs || false} collapsed={this.props.collapseLhs || this.state.collapseLhs || false}
disabled={this.props.leftDisabled} disabled={this.props.leftDisabled}
/> />

View file

@ -313,6 +313,12 @@ const RoomSubList = React.createClass({
} }
}, },
setHeight: function(height) {
if (this.refs.subList) {
this.refs.subList.style.height = `${height}px`;
}
},
render: function() { render: function() {
const len = this.props.list.length + this.props.extraTiles.length; const len = this.props.list.length + this.props.extraTiles.length;
if (len) { if (len) {
@ -322,13 +328,13 @@ const RoomSubList = React.createClass({
"mx_RoomSubList_nonEmpty": len && !this.state.hidden, "mx_RoomSubList_nonEmpty": len && !this.state.hidden,
}); });
if (this.state.hidden) { if (this.state.hidden) {
return <div className={subListClasses}> return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx()} {this._getHeaderJsx()}
</div>; </div>;
} else { } else {
const tiles = this.makeRoomTiles(); const tiles = this.makeRoomTiles();
tiles.push(...this.props.extraTiles); tiles.push(...this.props.extraTiles);
return <div className={subListClasses}> return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx()} {this._getHeaderJsx()}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll"> <IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll">
{ tiles } { tiles }
@ -343,7 +349,7 @@ const RoomSubList = React.createClass({
} }
return ( return (
<div className="mx_RoomSubList"> <div ref="subList" className="mx_RoomSubList">
{ this._getHeaderJsx() } { this._getHeaderJsx() }
{ content } { content }
</div> </div>

View file

@ -36,7 +36,8 @@ import GroupStore from '../../../stores/GroupStore';
import RoomSubList from '../../structures/RoomSubList'; import RoomSubList from '../../structures/RoomSubList';
import ResizeHandle from '../elements/ResizeHandle'; import ResizeHandle from '../elements/ResizeHandle';
import {Resizer, RoomSubListDistributor} from '../../../resizer' import {Resizer} from '../../../resizer'
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
const HIDE_CONFERENCE_CHANS = true; const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/; const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
@ -79,6 +80,23 @@ module.exports = React.createClass({
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed"); const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
this.subListSizes = sizesJson ? JSON.parse(sizesJson) : {}; this.subListSizes = sizesJson ? JSON.parse(sizesJson) : {};
this.collapsedState = collapsedJson ? JSON.parse(collapsedJson) : {}; this.collapsedState = collapsedJson ? JSON.parse(collapsedJson) : {};
this._layoutSections = [];
this._layout = new Layout((key, size) => {
const subList = this._subListRefs[key];
if (subList) {
subList.setHeight(size);
}
// update overflow indicators
this._checkSubListsOverflow();
// don't store height for collapsed sublists
if(!this.collapsedState[key]) {
this.subListSizes[key] = size;
window.localStorage.setItem("mx_roomlist_sizes",
JSON.stringify(this.subListSizes));
}
}, this.subListSizes, this.collapsedState);
return { return {
isLoadingLeftRooms: false, isLoadingLeftRooms: false,
totalRoomCount: null, totalRoomCount: null,
@ -146,54 +164,38 @@ module.exports = React.createClass({
this._delayedRefreshRoomListLoopCount = 0; this._delayedRefreshRoomListLoopCount = 0;
}, },
_onSubListResize: function(newSize, id) {
if (!id) {
return;
}
if (typeof newSize === "string") {
newSize = Number.MAX_SAFE_INTEGER;
}
if (newSize === null) {
delete this.subListSizes[id];
} else {
this.subListSizes[id] = newSize;
}
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.subListSizes));
// update overflow indicators
this._checkSubListsOverflow();
},
componentDidMount: function() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
const cfg = { const cfg = {
onResized: this._onSubListResize, layout: this._layout,
}; };
this.resizer = new Resizer(this.resizeContainer, RoomSubListDistributor, cfg); this.resizer = new Resizer(this.resizeContainer, Distributor, cfg);
this.resizer.setClassNames({ this.resizer.setClassNames({
handle: "mx_ResizeHandle", handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical", vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse" reverse: "mx_ResizeHandle_reverse"
}); });
this._layout.update(
// load stored sizes this._layoutSections,
Object.keys(this.subListSizes).forEach((key) => { this.resizeContainer && this.resizeContainer.offsetHeight,
this._restoreSubListSize(key); );
});
this._checkSubListsOverflow(); this._checkSubListsOverflow();
this.resizer.attach(); this.resizer.attach();
window.addEventListener("resize", this.onWindowResize);
this.mounted = true; this.mounted = true;
}, },
componentDidUpdate: function(prevProps) { componentDidUpdate: function(prevProps) {
this._repositionIncomingCallBox(undefined, false); this._repositionIncomingCallBox(undefined, false);
if (this.props.searchFilter !== prevProps.searchFilter) { // if (this.props.searchFilter !== prevProps.searchFilter) {
// restore sizes // this._checkSubListsOverflow();
Object.keys(this.subListSizes).forEach((key) => { // }
this._restoreSubListSize(key); this._layout.update(
}); this._layoutSections,
this._checkSubListsOverflow(); this.resizeContainer && this.resizeContainer.clientHeight,
} );
// TODO: call layout.setAvailableHeight, window height was changed when bannerShown prop was changed
}, },
onAction: function(payload) { onAction: function(payload) {
@ -222,6 +224,7 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
this.mounted = false; this.mounted = false;
window.removeEventListener("resize", this.onWindowResize);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room", this.onRoom);
@ -251,6 +254,17 @@ module.exports = React.createClass({
this._delayedRefreshRoomList.cancelPendingCall(); this._delayedRefreshRoomList.cancelPendingCall();
}, },
onWindowResize: function() {
if (this.mounted && this._layout && this.resizeContainer &&
Array.isArray(this._layoutSections)
) {
this._layout.update(
this._layoutSections,
this.resizeContainer.offsetHeight
);
}
},
onRoom: function(room) { onRoom: function(room) {
this.updateVisibleRooms(); this.updateVisibleRooms();
}, },
@ -551,22 +565,16 @@ module.exports = React.createClass({
this.collapsedState[key] = collapsed; this.collapsedState[key] = collapsed;
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.collapsedState)); window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.collapsedState));
// load the persisted size configuration of the expanded sub list // load the persisted size configuration of the expanded sub list
if (!collapsed) { if (collapsed) {
this._restoreSubListSize(key); this._layout.collapseSection(key);
} else {
this._layout.expandSection(key, this.subListSizes[key]);
} }
// check overflow, as sub lists sizes have changed // check overflow, as sub lists sizes have changed
// important this happens after calling resize above // important this happens after calling resize above
this._checkSubListsOverflow(); this._checkSubListsOverflow();
}, },
_restoreSubListSize(key) {
const size = this.subListSizes[key];
const handle = this.resizer.forHandleWithId(key);
if (handle) {
handle.resize(size);
}
},
// check overflow for scroll indicator gradient // check overflow for scroll indicator gradient
_checkSubListsOverflow() { _checkSubListsOverflow() {
Object.values(this._subListRefs).forEach(l => l.checkOverflow()); Object.values(this._subListRefs).forEach(l => l.checkOverflow());
@ -581,6 +589,7 @@ module.exports = React.createClass({
}, },
_mapSubListProps: function(subListsProps) { _mapSubListProps: function(subListsProps) {
this._layoutSections = [];
const defaultProps = { const defaultProps = {
collapsed: this.props.collapsed, collapsed: this.props.collapsed,
isFiltered: !!this.props.searchFilter, isFiltered: !!this.props.searchFilter,
@ -599,6 +608,7 @@ module.exports = React.createClass({
return subListsProps.reduce((components, props, i) => { return subListsProps.reduce((components, props, i) => {
props = Object.assign({}, defaultProps, props); props = Object.assign({}, defaultProps, props);
const isLast = i === subListsProps.length - 1; const isLast = i === subListsProps.length - 1;
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
const {key, label, onHeaderClick, ... otherProps} = props; const {key, label, onHeaderClick, ... otherProps} = props;
const chosenKey = key || label; const chosenKey = key || label;
const onSubListHeaderClick = (collapsed) => { const onSubListHeaderClick = (collapsed) => {
@ -608,7 +618,10 @@ module.exports = React.createClass({
} }
}; };
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey]; const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
this._layoutSections.push({
id: chosenKey,
count: len,
});
let subList = (<RoomSubList let subList = (<RoomSubList
ref={this._subListRef.bind(this, chosenKey)} ref={this._subListRef.bind(this, chosenKey)}
startAsHidden={startAsHidden} startAsHidden={startAsHidden}

View file

@ -0,0 +1,305 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import FixedDistributor from "./fixed";
// const allowWhitespace = true;
const handleHeight = 1;
function log() {
}
function clamp(height, min, max) {
//log(`clamping ${height} between ${min} and ${max}`);
if (height > max) return max;
if (height < min) return min;
return height;
}
export class Layout {
constructor(applyHeight, initialSizes, collapsedState) {
this._applyHeight = applyHeight;
this._sections = [];
this._collapsedState = Object.assign({}, collapsedState);
this._availableHeight = 0;
// heights stored by section section id
this._sectionHeights = Object.assign({}, initialSizes);
// in-progress heights, while dragging. Committed on mouse-up.
this._heights = [];
}
setAvailableHeight(newSize) {
this._availableHeight = newSize;
// needs more work
this._applyNewSize();
}
expandSection(id, height) {
this._collapsedState[id] = false;
this._applyNewSize();
this.openHandle(id).setHeight(height).finish();
}
collapseSection(id) {
this._collapsedState[id] = true;
this._applyNewSize();
}
// [{id, count}]
update(sections, availableHeight) {
if (Number.isFinite(availableHeight)) {
this._availableHeight = availableHeight;
}
const totalHeight = this._getAvailableHeight();
this._sections.forEach((section, i) => {
if (!this._sectionHeights.hasOwnProperty(section.id)) {
this._sectionHeights[section.id] = clamp(
totalHeight / this._sections.length,
this._getMinHeight(i),
this._getMaxHeight(i),
);
}
});
this._sections = sections;
this._applyNewSize();
}
openHandle(id) {
const index = this._getSectionIndex(id);
//log(`openHandle resolved ${id} to ${index}`);
return new Handle(this, index, this._sectionHeights[id]);
}
_getAvailableHeight() {
const nonCollapsedSectionCount = this._sections.reduce((count, section) => {
const collapsed = this._collapsedState[section.id];
return count + (collapsed ? 0 : 1);
}, 0);
return this._availableHeight - ((nonCollapsedSectionCount - 1) * handleHeight);
}
_applyNewSize() {
const newHeight = this._getAvailableHeight();
const currHeight = this._sections.reduce((sum, section) => {
return sum + this._sectionHeights[section.id];
}, 0);
const offset = newHeight - currHeight;
this._heights = this._sections.map((section) => this._sectionHeights[section.id]);
const sections = this._sections.map((_, i) => i);
this._applyOverflow(-offset, sections, true);
this._applyHeights();
this._commitHeights();
}
_getSectionIndex(id) {
return this._sections.findIndex((s) => s.id === id);
}
_getMaxHeight(i) {
const section = this._sections[i];
const collapsed = this._collapsedState[section.id];
if (collapsed) {
return this._sectionHeight(0);
} else {
return 100000;
// return this._sectionHeight(section.count);
}
}
_sectionHeight(count) {
return 36 + (count === 0 ? 0 : 4 + (count * 34));
}
_getMinHeight(i) {
const section = this._sections[i];
const collapsed = this._collapsedState[section.id];
const maxItems = collapsed ? 0 : 1;
// log("_getMinHeight", i, section);
return this._sectionHeight(Math.min(section.count, maxItems));
}
_applyOverflow(overflow, sections, blend) {
//log("applyOverflow", overflow, sections);
// take the given overflow amount, and applies it to the given sections.
// calls itself recursively until it has distributed all the overflow
// or run out of unclamped sections.
const unclampedSections = [];
let overflowPerSection = blend ? (overflow / sections.length) : overflow;
for (const i of sections) {
const newHeight = clamp(
this._heights[i] - overflowPerSection,
this._getMinHeight(i),
this._getMaxHeight(i),
);
if (newHeight == this._heights[i] - overflowPerSection) {
unclampedSections.push(i);
}
// when section is growing, overflow increases?
// 100 -= 200 - 300
// 100 -= -100
// 200
overflow -= this._heights[i] - newHeight;
// console.log(`this._heights[${i}] (${this._heights[i]}) - newHeight (${newHeight}) = ${this._heights[i] - newHeight}`);
// console.log(`changing ${this._heights[i]} to ${newHeight}`);
this._heights[i] = newHeight;
// console.log(`for section ${i} overflow is ${overflow}`);
if (!blend) {
overflowPerSection = overflow;
if (Math.abs(overflow) < 1.0) break;
}
}
if (Math.abs(overflow) > 1.0 && unclampedSections.length > 0) {
// we weren't able to distribute all the overflow so recurse and try again
// log("recursing with", overflow, unclampedSections);
overflow = this._applyOverflow(overflow, unclampedSections, blend);
}
return overflow;
}
_rebalanceAbove(anchor, overflowAbove) {
if (Math.abs(overflowAbove) > 1.0) {
// log(`trying to rebalance upstream with ${overflowAbove}`);
const sections = [];
for (let i = anchor - 1; i >= 0; i--) {
sections.push(i);
}
overflowAbove = this._applyOverflow(overflowAbove, sections);
}
return overflowAbove;
}
_rebalanceBelow(anchor, overflowBelow) {
if (Math.abs(overflowBelow) > 1.0) {
// log(`trying to rebalance downstream with ${overflowBelow}`);
const sections = [];
for (let i = anchor + 1; i < this._sections.length; i++) {
sections.push(i);
}
overflowBelow = this._applyOverflow(overflowBelow, sections);
//log(`rebalanced downstream with ${overflowBelow}`);
}
return overflowBelow;
}
// @param offset the amount the anchor is moved from what is stored in _sectionHeights, positive if downwards
// if we're clamped, return the offset we should be clamped at.
_relayout(anchor = 0, offset = 0, clamped = false) {
this._heights = this._sections.map((section) => this._sectionHeights[section.id]);
// are these the amounts the items above/below shrank/grew and need to be relayouted?
let overflowAbove;
let overflowBelow;
const maxHeight = this._getMaxHeight(anchor);
const minHeight = this._getMinHeight(anchor);
// new height > max ?
if (this._heights[anchor] + offset > maxHeight) {
// we're pulling downwards and clamped
// overflowAbove = minus how much are we above max height?
overflowAbove = (maxHeight - this._heights[anchor]) - offset;
overflowBelow = offset;
// log(`pulling downwards clamped at max: ${overflowAbove} ${overflowBelow}`);
} else if (this._heights[anchor] + offset < minHeight) { // new height < min?
// we're pulling upwards and clamped
// overflowAbove = ??? (offset is negative here, so - offset will add)
overflowAbove = (minHeight - this._heights[anchor]) - offset;
overflowBelow = offset;
// log(`pulling upwards clamped at min: ${overflowAbove} ${overflowBelow}`);
} else {
overflowAbove = 0;
overflowBelow = offset;
// log(`resizing the anchor: ${overflowAbove} ${overflowBelow}`);
}
this._heights[anchor] = clamp(this._heights[anchor] + offset, minHeight, maxHeight);
// these are reassigned the amount of overflow that could not be rebalanced
// meaning we dragged the handle too far and it can't follow the cursor anymore
overflowAbove = this._rebalanceAbove(anchor, overflowAbove);
overflowBelow = this._rebalanceBelow(anchor, overflowBelow);
if (!clamped) { // to avoid risk of infinite recursion
// clamp to avoid overflowing or underflowing the page
if (Math.abs(overflowAbove) > 1.0) {
// log(`clamping with overflowAbove ${overflowAbove}`);
// here we do the layout again with offset - the amount of space we took too much
this._relayout(anchor, offset + overflowAbove, true);
return offset + overflowAbove;
}
if (Math.abs(overflowBelow) > 1.0) {
// here we do the layout again with offset - the amount of space we took too much
// log(`clamping with overflowBelow ${overflowBelow}`);
this._relayout(anchor, offset - overflowBelow, true);
return offset - overflowBelow;
}
}
this._applyHeights();
return undefined;
}
_applyHeights() {
log("updating layout, heights are now", this._heights);
// apply the heights
for (let i = 0; i < this._sections.length; i++) {
const section = this._sections[i];
this._applyHeight(section.id, this._heights[i]);
}
}
_commitHeights() {
this._sections.forEach((section, i) => {
this._sectionHeights[section.id] = this._heights[i];
});
}
}
class Handle {
constructor(layout, anchor, height) {
this._layout = layout;
this._anchor = anchor;
this._initialHeight = height;
}
setHeight(height) {
this._layout._relayout(this._anchor, height - this._initialHeight);
return this;
}
finish() {
this._layout._commitHeights();
return this;
}
}
export class Distributor extends FixedDistributor {
constructor(item, cfg) {
super(item);
const layout = cfg.layout;
this._handle = layout.openHandle(item.id);
}
finish() {
this._handle.finish();
}
resize(height) {
this._handle.setHeight(height);
}
}