first impl of new scrolling, still a bit broken

This commit is contained in:
Bruno Windels 2019-03-19 13:42:22 +01:00
parent b26f733c9c
commit 71f6b08b26
3 changed files with 174 additions and 136 deletions

View file

@ -19,6 +19,7 @@
@import "./structures/_RoomStatusBar.scss";
@import "./structures/_RoomSubList.scss";
@import "./structures/_RoomView.scss";
@import "./structures/_ScrollPanel.scss";
@import "./structures/_SearchBox.scss";
@import "./structures/_TabbedView.scss";
@import "./structures/_TagPanel.scss";

View file

@ -0,0 +1,26 @@
/*
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.
*/
.mx_ScrollPanel {
.mx_RoomView_MessageList {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow-y: hidden;
}
}

View file

@ -18,6 +18,7 @@ const React = require("react");
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard';
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
const DEBUG_SCROLL = false;
@ -30,11 +31,14 @@ const UNPAGINATION_PADDING = 6000;
// many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
const PAGE_SIZE = 200;
let debuglog;
if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
debuglog = console.log.bind(console, "ScrollPanel debuglog:");
} else {
var debuglog = function() {};
debuglog = function() {};
}
/* This component implements an intelligent scrolling list.
@ -186,56 +190,12 @@ module.exports = React.createClass({
},
onScroll: function(ev) {
const sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop,
"_lastSetScroll:", this._lastSetScroll);
// ignore scroll events where scrollTop hasn't changed,
// appears to happen when the layout changes outside
// of the scroll container, like resizing the right panel.
if (sn.scrollTop === this._lastEventScroll) {
debuglog("ignore scroll event with same scrollTop as before");
return;
}
this._lastEventScroll = sn.scrollTop;
// Sometimes we see attempts to write to scrollTop essentially being
// ignored. (Or rather, it is successfully written, but on the next
// scroll event, it's been reset again).
//
// This was observed on Chrome 47, when scrolling using the trackpad in OS
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
// due to Chrome not being able to cope with the scroll offset being reset
// while a two-finger drag is in progress.
//
// By way of a workaround, we detect this situation and just keep
// resetting scrollTop until we see the scroll node have the right
// value.
if (this._lastSetScroll !== undefined && sn.scrollTop < this._lastSetScroll-200) {
console.log("Working around vector-im/vector-web#528");
this._restoreSavedScrollState();
return;
}
// If there weren't enough children to fill the viewport, the scroll we
// got might be different to the scroll we wanted; we don't want to
// forget what we wanted, so don't overwrite the saved state unless
// this appears to be a user-initiated scroll.
if (sn.scrollTop != this._lastSetScroll) {
this._scrollTimeout.restart();
this._saveScrollState();
} else {
debuglog("Ignoring scroll echo");
// only ignore the echo once, otherwise we'll get confused when the
// user scrolls away from, and back to, the autoscroll point.
this._lastSetScroll = undefined;
}
this._checkBlockShrinking();
this.checkFillState();
this.props.onScroll(ev);
this.checkFillState();
},
onResize: function() {
@ -258,14 +218,7 @@ module.exports = React.createClass({
// whether it will stay that way when the children update.
isAtBottom: function() {
const sn = this._getScrollNode();
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
// understanding of the box model, wherein the scrollNode ends up 2
// pixels higher than the available space, even when there are less
// than a screenful of messages. + 3 is a fudge factor to pretend
// that we're at the bottom when we're still a few pixels off.
return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3;
return sn.scrollTop === sn.scrollHeight - sn.clientHeight;
},
// returns the vertical height in the given direction that can be removed from
@ -301,10 +254,15 @@ module.exports = React.createClass({
// `---------' -
_getExcessHeight: function(backwards) {
const sn = this._getScrollNode();
const contentHeight = this._getMessagesHeight();
const listHeight = this._getListHeight();
const clippedHeight = contentHeight - listHeight;
const unclippedScrollTop = sn.scrollTop + clippedHeight;
if (backwards) {
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else {
return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
}
},
@ -356,6 +314,9 @@ module.exports = React.createClass({
if (excessHeight <= 0) {
return;
}
const origExcessHeight = excessHeight;
const tiles = this.refs.itemlist.children;
// The scroll token of the first/last tile to be unpaginated
@ -367,8 +328,9 @@ module.exports = React.createClass({
// pagination.
//
// If backwards is true, we unpaginate (remove) tiles from the back (top).
let tile;
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[backwards ? i : tiles.length - 1 - i];
tile = tiles[backwards ? i : tiles.length - 1 - i];
// Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight;
//If removing the tile would lead to future pagination, break before setting scroll token
@ -380,6 +342,7 @@ module.exports = React.createClass({
markerScrollToken = tile.dataset.scrollTokens.split(',')[0];
}
}
debuglog("unfilling now", backwards, origExcessHeight, Array.prototype.indexOf.call(tiles, tile));
if (markerScrollToken) {
// Use a debouncer to prevent multiple unfill calls in quick succession
@ -439,7 +402,7 @@ module.exports = React.createClass({
* false, the first token in data-scroll-tokens of the child which we are
* tracking.
*
* number pixelOffset: undefined if stuckAtBottom is true; if it is false,
* number bottomOffset: undefined if stuckAtBottom is true; if it is false,
* the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel.
*/
@ -460,14 +423,20 @@ module.exports = React.createClass({
* child list.)
*/
resetScrollState: function() {
this.scrollState = {stuckAtBottom: this.props.startAtBottom};
this.scrollState = {
stuckAtBottom: this.props.startAtBottom,
};
this._bottomGrowth = 0;
this._pages = 0;
this._scrollTimeout = new Timer(100);
this._heightUpdateInProgress = false;
},
/**
* jump to the top of the content.
*/
scrollToTop: function() {
this._setScrollTop(0);
this._getScrollNode().scrollTop = 0;
this._saveScrollState();
},
@ -479,24 +448,26 @@ module.exports = React.createClass({
// saved is to do the scroll, then save the updated state. (Calculating
// it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here).
this._setScrollTop(Number.MAX_VALUE);
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
this._saveScrollState();
},
/**
* Page up/down.
*
* mult: -1 to page up, +1 to page down
* @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
this._setScrollTop(scrollNode.scrollTop + delta);
scrollNode.scrollTop = scrollNode.scrollTop + delta;
this._saveScrollState();
},
/**
* Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event
*/
handleScrollKey: function(ev) {
switch (ev.keyCode) {
@ -529,21 +500,21 @@ module.exports = React.createClass({
/* Scroll the panel to bring the DOM node with the scroll token
* `scrollToken` into view.
*
* offsetBase gives the reference point for the pixelOffset. 0 means the
* offsetBase gives the reference point for the bottomOffset. 0 means the
* top of the container, 1 means the bottom, and fractional values mean
* somewhere in the middle. If omitted, it defaults to 0.
*
* pixelOffset gives the number of pixels *above* the offsetBase that the
* bottomOffset gives the number of pixels *above* the offsetBase that the
* node (specifically, the bottom of it) will be positioned. If omitted, it
* defaults to 0.
*/
scrollToToken: function(scrollToken, pixelOffset, offsetBase) {
pixelOffset = pixelOffset || 0;
scrollToToken: function(scrollToken, bottomOffset, offsetBase) {
bottomOffset = bottomOffset || 0;
offsetBase = offsetBase || 0;
// convert pixelOffset so that it is based on the bottom of the
// convert bottomOffset so that it is based on the bottom of the
// container.
pixelOffset += this._getScrollNode().clientHeight * (1-offsetBase);
bottomOffset += this._getScrollNode().clientHeight * (1-offsetBase);
// save the desired scroll state. It's important we do this here rather
// than as a result of the scroll event, because (a) we might not *get*
@ -554,50 +525,13 @@ module.exports = React.createClass({
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: scrollToken,
pixelOffset: pixelOffset,
bottomOffset: bottomOffset,
};
// ... then make it so.
this._restoreSavedScrollState();
},
// set the scrollTop attribute appropriately to position the given child at the
// given offset in the window. A helper for _restoreSavedScrollState.
_scrollToToken: function(scrollToken, pixelOffset) {
/* find the dom node with the right scrolltoken */
let node;
const messages = this.refs.itemlist.children;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
}
if (!node) {
debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'");
return;
}
const scrollNode = this._getScrollNode();
const scrollTop = scrollNode.scrollTop;
const viewportBottom = scrollTop + scrollNode.clientHeight;
const nodeBottom = node.offsetTop + node.clientHeight;
const intendedViewportBottom = nodeBottom + pixelOffset;
const scrollDelta = intendedViewportBottom - viewportBottom;
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
if (scrollDelta !== 0) {
this._setScrollTop(scrollTop + scrollDelta);
}
},
_saveScrollState: function() {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
@ -606,12 +540,13 @@ module.exports = React.createClass({
}
const scrollNode = this._getScrollNode();
const viewportBottom = scrollNode.scrollTop + scrollNode.clientHeight;
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this.refs.itemlist;
const messages = itemlist.children;
let node = null;
// TODO: do a binary search here, as items are sorted by offsetTop
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length-1; i >= 0; --i) {
if (!messages[i].dataset.scrollTokens) {
@ -631,12 +566,12 @@ module.exports = React.createClass({
return;
}
const nodeBottom = node.offsetTop + node.clientHeight;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
debuglog("ScrollPanel: replacing scroll state");
this.scrollState = {
stuckAtBottom: false,
trackedNode: node,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: viewportBottom - nodeBottom,
bottomOffset: this._topFromBottom(node),
};
},
@ -644,35 +579,111 @@ module.exports = React.createClass({
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE);
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
} else if (scrollState.trackedScrollToken) {
this._scrollToToken(scrollState.trackedScrollToken,
scrollState.pixelOffset);
const itemlist = this.refs.itemlist;
const trackedNode = this._getTrackedNode();
if (trackedNode) {
const newBottomOffset = this._topFromBottom(trackedNode);
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
this._bottomGrowth += bottomDiff;
scrollState.bottomOffset = newBottomOffset;
itemlist.style.height = `${this._getListHeight()}px`;
debuglog("ScrollPanel: balancing height because messages below viewport grew by "+bottomDiff+"px");
}
}
// TODO: also call _updateHeight if not already in progress
if (!this._heightUpdateInProgress) {
const heightDiff = this._getMessagesHeight() - this._getListHeight();
if (heightDiff > 0) {
this._updateHeight();
}
}
},
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
async _updateHeight() {
if (this._heightUpdateInProgress) {
return;
}
this._heightUpdateInProgress = true;
try {
// wait until user has stopped scrolling
if (this._scrollTimeout.isRunning()) {
await this._scrollTimeout.finished();
}
const sn = this._getScrollNode();
const itemlist = this.refs.itemlist;
const contentHeight = this._getMessagesHeight();
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
this._pages = Math.ceil(height / PAGE_SIZE);
this._bottomGrowth = 0;
const newHeight = this._getListHeight();
if (this.scrollState.stuckAtBottom) {
itemlist.style.height = `${newHeight}px`;
sn.scrollTop = sn.scrollHeight;
debuglog("updateHeight to", newHeight);
} else {
const trackedNode = this._getTrackedNode();
const oldTop = trackedNode.offsetTop;
itemlist.style.height = `${newHeight}px`;
const newTop = trackedNode.offsetTop;
const topDiff = newTop - oldTop;
sn.scrollTop = sn.scrollTop + topDiff;
debuglog("updateHeight to", newHeight, topDiff);
}
} finally {
this._heightUpdateInProgress = false;
}
},
_setScrollTop: function(scrollTop) {
const scrollNode = this._getScrollNode();
_getTrackedNode() {
const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode;
const prevScroll = scrollNode.scrollTop;
if (!trackedNode || !trackedNode.parentElement) {
let node;
const messages = this.refs.itemlist.children;
const scrollToken = scrollState.trackedScrollToken;
// FF ignores attempts to set scrollTop to very large numbers
scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight);
// If this change generates a scroll event, we should not update the
// saved scroll state on it. See the comments in onScroll.
//
// If we *don't* expect a scroll event, we need to leave _lastSetScroll
// alone, otherwise we'll end up ignoring a future scroll event which is
// nothing to do with this change.
if (scrollNode.scrollTop != prevScroll) {
this._lastSetScroll = scrollNode.scrollTop;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
}
debuglog("had to find tracked node again for " + scrollState.trackedScrollToken);
scrollState.trackedNode = node;
}
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll);
if (!scrollState.trackedNode) {
debuglog("ScrollPanel: No node with ; '"+scrollState.trackedScrollToken+"'");
return;
}
return scrollState.trackedNode;
},
_getListHeight() {
return this._bottomGrowth + (this._pages * PAGE_SIZE);
},
_getMessagesHeight() {
const itemlist = this.refs.itemlist;
const lastNode = itemlist.lastElementChild;
// 18 is itemlist padding
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
},
_topFromBottom(node) {
return this.refs.itemlist.clientHeight - node.offsetTop;
},
/* get the DOM node which has the scrollTop property we care about for our
@ -742,7 +753,7 @@ module.exports = React.createClass({
// it's not obvious why we have a separate div and ol anyway.
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll}
className={this.props.className} style={this.props.style}>
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{ this.props.children }