Merge pull request #2676 from matrix-org/bwindels/improvedscrolling

Improved scrolling & pagination
This commit is contained in:
Bruno Windels 2019-03-01 11:09:23 +01:00 committed by GitHub
commit fe3fe208e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 94 additions and 115 deletions

View file

@ -7,4 +7,4 @@
set -ev set -ev
scripts/travis/build.sh scripts/travis/build.sh
npm run test CHROME_BIN='/usr/bin/google-chrome-stable' npm run test

View file

@ -635,9 +635,9 @@ module.exports = React.createClass({
_onTypingVisible: function() { _onTypingVisible: function() {
const scrollPanel = this.refs.scrollPanel; const scrollPanel = this.refs.scrollPanel;
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
scrollPanel.blockShrinking();
// scroll down if at bottom // scroll down if at bottom
scrollPanel.checkScroll(); scrollPanel.checkScroll();
scrollPanel.blockShrinking();
} }
}, },
@ -648,12 +648,23 @@ module.exports = React.createClass({
const isAtBottom = scrollPanel.isAtBottom(); const isAtBottom = scrollPanel.isAtBottom();
const whoIsTyping = this.refs.whoIsTyping; const whoIsTyping = this.refs.whoIsTyping;
const isTypingVisible = whoIsTyping && whoIsTyping.isVisible(); const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
// when messages get added to the timeline,
// but somebody else is still typing,
// update the min-height, so once the last
// person stops typing, no jumping occurs
if (isAtBottom && isTypingVisible) { if (isAtBottom && isTypingVisible) {
scrollPanel.blockShrinking(); scrollPanel.blockShrinking();
} }
} }
}, },
clearTimelineHeight: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.clearBlockShrinking();
}
},
onResize: function() { onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true); dis.dispatch({ action: 'timeline_resize' }, true);
}, },

View file

@ -78,6 +78,27 @@ if (DEBUG_SCROLL) {
* scroll down further. If stickyBottom is disabled, we just save the scroll * scroll down further. If stickyBottom is disabled, we just save the scroll
* offset as normal. * offset as normal.
*/ */
function createTimelineResizeDetector(scrollNode, itemlist, callback) {
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(callback);
ro.observe(itemlist);
return ro;
} else if (typeof IntersectionObserver !== "undefined") {
const threshold = [];
for (let i = 0; i <= 1000; ++i) {
threshold.push(i / 1000);
}
const io = new IntersectionObserver(
callback,
{root: scrollNode, threshold},
);
io.observe(itemlist);
return io;
}
}
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'ScrollPanel', displayName: 'ScrollPanel',
@ -160,6 +181,12 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
this.checkScroll(); this.checkScroll();
this._timelineSizeObserver = createTimelineResizeDetector(
this._getScrollNode(),
this.refs.itemlist,
() => { this._restoreSavedScrollState(); },
);
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
@ -169,10 +196,6 @@ module.exports = React.createClass({
// //
// This will also re-check the fill state, in case the paginate was inadequate // This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll(); this.checkScroll();
if (!this.isAtBottom()) {
this.clearBlockShrinking();
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -181,6 +204,10 @@ module.exports = React.createClass({
// //
// (We could use isMounted(), but facebook have deprecated that.) // (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true; this.unmounted = true;
if (this._timelineSizeObserver) {
this._timelineSizeObserver.disconnect();
this._timelineSizeObserver = null;
}
}, },
onScroll: function(ev) { onScroll: function(ev) {
@ -188,46 +215,21 @@ module.exports = React.createClass({
debuglog("Scroll event: offset now:", sn.scrollTop, debuglog("Scroll event: offset now:", sn.scrollTop,
"_lastSetScroll:", this._lastSetScroll); "_lastSetScroll:", this._lastSetScroll);
// 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 // 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 // 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 // forget what we wanted, so don't overwrite the saved state unless
// this appears to be a user-initiated scroll. // this appears to be a user-initiated scroll.
if (sn.scrollTop != this._lastSetScroll) { if (sn.scrollTop != this._lastSetScroll) {
// when scrolling, we don't care about disappearing typing notifs shrinking the timeline
// this might cause the scrollbar to resize in case the max-height was not correct
// but that's better than ending up with a lot of whitespace at the bottom of the timeline.
// we need to above check because when showing the typing notifs, an onScroll event is also triggered
if (!this.isAtBottom()) {
this.clearBlockShrinking();
}
this._saveScrollState(); this._saveScrollState();
} else { } else {
debuglog("Ignoring scroll echo"); debuglog("Ignoring scroll echo");
// only ignore the echo once, otherwise we'll get confused when the // only ignore the echo once, otherwise we'll get confused when the
// user scrolls away from, and back to, the autoscroll point. // user scrolls away from, and back to, the autoscroll point.
this._lastSetScroll = undefined; this._lastSetScroll = undefined;
} }
this._checkBlockShrinking();
this.props.onScroll(ev); this.props.onScroll(ev);
this.checkFillState(); this.checkFillState();
@ -235,8 +237,6 @@ module.exports = React.createClass({
onResize: function() { onResize: function() {
this.props.onResize(); this.props.onResize();
// clear min-height as the height might have changed
this.clearBlockShrinking();
this.checkScroll(); this.checkScroll();
if (this._gemScroll) this._gemScroll.forceUpdate(); if (this._gemScroll) this._gemScroll.forceUpdate();
}, },
@ -245,6 +245,7 @@ module.exports = React.createClass({
// where it ought to be, and set off pagination requests if necessary. // where it ought to be, and set off pagination requests if necessary.
checkScroll: function() { checkScroll: function() {
this._restoreSavedScrollState(); this._restoreSavedScrollState();
this._checkBlockShrinking();
this.checkFillState(); this.checkFillState();
}, },
@ -386,8 +387,6 @@ module.exports = React.createClass({
} }
this._unfillDebouncer = setTimeout(() => { this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null; this._unfillDebouncer = null;
// if timeline shrinks, min-height should be cleared
this.clearBlockShrinking();
this.props.onUnfillRequest(backwards, markerScrollToken); this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS); }, UNFILL_REQUEST_DEBOUNCE_MS);
} }
@ -583,9 +582,10 @@ module.exports = React.createClass({
} }
const scrollNode = this._getScrollNode(); const scrollNode = this._getScrollNode();
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); const scrollBottom = scrollNode.scrollTop + scrollNode.clientHeight;
const boundingRect = node.getBoundingClientRect();
const scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; const nodeBottom = node.offsetTop + node.clientHeight;
const scrollDelta = nodeBottom + pixelOffset - scrollBottom;
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" + debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")"); pixelOffset + " (delta: "+scrollDelta+")");
@ -602,42 +602,43 @@ module.exports = React.createClass({
return; return;
} }
const scrollNode = this._getScrollNode();
const scrollBottom = scrollNode.scrollTop + scrollNode.clientHeight;
const itemlist = this.refs.itemlist; const itemlist = this.refs.itemlist;
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const messages = itemlist.children; const messages = itemlist.children;
let newScrollState = null; let node = null;
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length-1; i >= 0; --i) { for (let i = messages.length-1; i >= 0; --i) {
const node = messages[i]; if (!messages[i].dataset.scrollTokens) {
if (!node.dataset.scrollTokens) continue; continue;
}
const boundingRect = node.getBoundingClientRect(); node = messages[i];
newScrollState = { // break at the first message (coming from the bottom)
stuckAtBottom: false, // that has it's offsetTop above the bottom of the viewport.
trackedScrollToken: node.dataset.scrollTokens.split(',')[0], if (node.offsetTop < scrollBottom) {
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
// If the bottom of the panel intersects the ClientRect of node, use this node
// as the scrollToken.
// If this is false for the entire for-loop, we default to the last node
// (which is why newScrollState is set on every iteration).
if (boundingRect.top < wrapperRect.bottom) {
// Use this node as the scrollToken // Use this node as the scrollToken
break; break;
} }
} }
// This is only false if there were no nodes with `node.dataset.scrollTokens` set.
if (newScrollState) { if (!node) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
} else {
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
return;
} }
const nodeBottom = node.offsetTop + node.clientHeight;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: scrollBottom - nodeBottom,
};
}, },
_restoreSavedScrollState: function() { _restoreSavedScrollState: function() {
const scrollState = this.scrollState; const scrollState = this.scrollState;
const scrollNode = this._getScrollNode();
if (scrollState.stuckAtBottom) { if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE); this._setScrollTop(Number.MAX_VALUE);
@ -717,6 +718,21 @@ module.exports = React.createClass({
} }
}, },
_checkBlockShrinking: function() {
const sn = this._getScrollNode();
const scrollState = this.scrollState;
if (!scrollState.stuckAtBottom) {
const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight);
// only if we've scrolled up 200px from the bottom
// should we clear the min-height used by the typing notifications,
// otherwise we might still see it jump as the whitespace disappears
// when scrolling up from the bottom
if (spaceBelowViewport >= 200) {
this.clearBlockShrinking();
}
}
},
render: function() { render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// TODO: the classnames on the div and ol could do with being updated to // TODO: the classnames on the div and ol could do with being updated to

View file

@ -935,6 +935,11 @@ var TimelinePanel = React.createClass({
{windowLimit: this.props.timelineCap}); {windowLimit: this.props.timelineCap});
const onLoaded = () => { const onLoaded = () => {
// clear the timeline min-height when
// (re)loading the timeline
if (this.refs.messagePanel) {
this.refs.messagePanel.clearTimelineHeight();
}
this._reloadEvents(); this._reloadEvents();
// If we switched away from the room while there were pending // If we switched away from the room while there were pending

View file

@ -224,57 +224,4 @@ describe('ScrollPanel', function() {
expect(scrollingDiv.scrollTop).toEqual(1950); expect(scrollingDiv.scrollTop).toEqual(1950);
}); });
}); });
it('should not get stuck in #528 workaround', function(done) {
let events = [];
Promise.resolve().then(() => {
// initialise with a bunch of events
for (let i = 0; i < 40; i++) {
events.push(i);
}
tester.setTileKeys(events);
expect(tester.fillCounts.b).toEqual(1);
expect(tester.fillCounts.f).toEqual(2);
expect(scrollingDiv.scrollHeight).toEqual(6050); // 40*150 + 50
expect(scrollingDiv.scrollTop).toEqual(6050 - 600);
// try to scroll up, to a non-integer offset.
tester.scrollPanel().scrollToToken("30", -101/3);
expect(scrollingDiv.scrollTop).toEqual(4616); // 31*150 - 34
// wait for the scroll event to land
return tester.awaitScroll(); // fails
}).then(() => {
expect(tester.lastScrollEvent).toEqual(4616);
// Now one more event; this will make it reset the scroll, but
// because the delta will be less than 1, will not trigger a
// scroll event, this leaving recentEventScroll defined.
console.log("Adding event 50");
events.push(50);
tester.setTileKeys(events);
// wait for the scrollpanel to stop trying to paginate
}).then(() => {
// Now, simulate hitting "scroll to bottom".
events = [];
for (let i = 100; i < 120; i++) {
events.push(i);
}
tester.setTileKeys(events);
tester.scrollPanel().scrollToBottom();
// wait for the scroll event to land
return tester.awaitScroll(); // fails
}).then(() => {
expect(scrollingDiv.scrollTop).toEqual(20*150 + 50 - 600);
// simulate a user-initiated scroll on the div
scrollingDiv.scrollTop = 1200;
return tester.awaitScroll();
}).then(() => {
expect(scrollingDiv.scrollTop).toEqual(1200);
}).done(done);
});
}); });