Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recursively check document height before scrollTo #217

Merged
merged 24 commits into from
Sep 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ module.exports = {
'addon/**',
'addon-test-support/**',
'app/**',
'tests/dummy/app/**'
'tests/dummy/app/**',
'tests/helpers/**'
],
parserOptions: {
sourceType: 'script',
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ needs:[ 'service:router-scroll', 'service:scheduler' ],

### Options

#### Target Elements

If you need to scroll to the top of an area that generates a vertical scroll bar, you can specify the id of an element
of the scrollable area. Default is `window` for using the scroll position of the whole viewport. You can pass an options
object in your application's `config/environment.js` file.
Expand All @@ -93,15 +95,31 @@ ENV['routerScroll'] = {
};
```

Moreover, if your route breaks up render into multiple phases, you may need to delay scrollTop functionality until after
the First Meaningful Paint using `delayScrollTop: true` in your config. `delayScrollTop` defaults to `false`.
#### Scroll Timing

You may want the default "out of the box" behaviour. We schedule scroll after Ember's `render`. This occurs on the tightest schedule between route transition start and end

However, you have other options. You may need to delay scroll functionality until after
the First Meaningful Paint using `scrollWhenPainted: true` in your config. `scrollWhenPainted` defaults to `false`.

Then next two config properties uses [`ember-app-scheduler`](https://github.com/ember-app-scheduler/ember-app-scheduler), so be sure to follow the instructions in the README. We include the `setupRouter` and `reset`. This all happens after `routeDidChange`.

```javascript
ENV['routerScroll'] = {
scrollWhenPainted: true
};
```

Also, if you need to perform the logic when the route is idle or if your route breaks up render into multiple phases, add `delayScrollTop: true` in your config. `delayScrollTop` defaults to `false`. This will be renamed to `scrollWhenIdle` in a major release.

```javascript
ENV['routerScroll'] = {
delayScrollTop: true
};
```

I would suggest trying all of them out and seeing which works best for your app!


## A working example

Expand Down
75 changes: 65 additions & 10 deletions addon/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,45 @@ import { get, getWithDefault, computed } from '@ember/object';
import { inject } from '@ember/service';
import { getOwner } from '@ember/application';
import { scheduleOnce } from '@ember/runloop';
import { setupRouter, reset, whenRouteIdle } from 'ember-app-scheduler';
import { setupRouter, reset, whenRouteIdle, whenRoutePainted } from 'ember-app-scheduler';
import { gte } from 'ember-compatibility-helpers';
import { getScrollBarWidth } from './utils/scrollbar-width';

let scrollBarWidth = getScrollBarWidth();
const body = document.body;
const html = document.documentElement;
let ATTEMPTS = 0;
const MAX_ATTEMPTS = 100; // rAF runs every 16ms ideally, so 60x a second
let requestId;

/**
* By default, we start checking to see if the document height is >= the last known `y` position
* we want to scroll to. This is important for content heavy pages that might try to scrollTo
* before the content has painted
*
* @method tryScrollRecursively
* @param {Function} fn
* @param {Object} scrollHash
* @void
*/
function tryScrollRecursively(fn, scrollHash) {
requestId = window.requestAnimationFrame(() => {
const documentWidth = Math.max(body.scrollWidth, body.offsetWidth,
html.clientWidth, html.scrollWidth, html.offsetWidth);
const documentHeight = Math.max(body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight);

if (documentWidth + scrollBarWidth - window.innerWidth >= scrollHash.x
&& documentHeight + scrollBarWidth - window.innerHeight >= scrollHash.y
|| ATTEMPTS >= MAX_ATTEMPTS) {
ATTEMPTS = 0;
fn.call(null, scrollHash.x, scrollHash.y);
} else {
ATTEMPTS++;
tryScrollRecursively(fn, scrollHash)
}
})
}

let RouterScrollMixin = Mixin.create({
service: inject('router-scroll'),
Expand Down Expand Up @@ -33,15 +70,21 @@ let RouterScrollMixin = Mixin.create({
destroy() {
reset();

if (requestId) {
window.cancelAnimationFrame(requestId);
}

this._super(...arguments);
},

/**
* Updates the scroll position
* @param {transition|transition[]} transition If before Ember 3.6, this will be an array of transitions, otherwise
* it will be a single transition
* @method updateScrollPosition
* @param {transition|transition[]} transition If before Ember 3.6, this will be an array of transitions, otherwise
* @param {Boolean} recursiveCheck - if "true", check until document height is >= y. `y` is the last coordinate the target page was on
*/
updateScrollPosition(transition) {
updateScrollPosition(transition, recursiveCheck) {
const url = get(this, 'currentURL');
const hashElement = url ? document.getElementById(url.split('#').pop()) : null;

Expand Down Expand Up @@ -74,10 +117,14 @@ let RouterScrollMixin = Mixin.create({
const scrollElement = get(this, 'service.scrollElement');
const targetElement = get(this, 'service.targetElement');

if (targetElement) {
window.scrollTo(scrollPosition.x, scrollPosition.y);
} else if ('window' === scrollElement) {
window.scrollTo(scrollPosition.x, scrollPosition.y);
if (targetElement || 'window' === scrollElement) {
if (recursiveCheck) {
// our own implementation
tryScrollRecursively(window.scrollTo, scrollPosition);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also want to do this if the page is optimistically rendered.

} else {
// using ember-app-scheduler
window.scrollTo(scrollPosition.x, scrollPosition.y);
}
} else if ('#' === scrollElement.charAt(0)) {
const element = document.getElementById(scrollElement.substring(1));

Expand All @@ -103,12 +150,20 @@ let RouterScrollMixin = Mixin.create({
}

const delayScrollTop = get(this, 'service.delayScrollTop');
const scrollWhenPainted = get(this, 'service.scrollWhenPainted');
const scrollWhenIdle = get(this, 'service.scrollWhenIdle');

if (!delayScrollTop) {
scheduleOnce('render', this, () => this.updateScrollPosition(transition));
} else {
if (!delayScrollTop && !scrollWhenPainted && !scrollWhenIdle) {
// out of the 3 options, this happens on the tightest schedule
scheduleOnce('render', this, () => this.updateScrollPosition(transition, true));
} else if (scrollWhenPainted) {
// as described in ember-app-scheduler, this addon can be used to delay rendering until after First Meaningful Paint.
// If you loading your routes progressively, this may be a good option to delay scrollTop until the remaining DOM elements are painted.
whenRoutePainted().then(() => {
this.updateScrollPosition(transition);
});
} else {
// as described in ember-app-scheduler, this addon can be used to delay rendering until after the route is idle
whenRouteIdle().then(() => {
this.updateScrollPosition(transition);
});
Expand Down
33 changes: 25 additions & 8 deletions addon/services/router-scroll.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ const RouterScroll = Service.extend({
key: null,
scrollElement: 'window',
targetElement: null,
delayScrollTop: false,
isFirstLoad: true,
preserveScrollPosition: false,
delayScrollTop: false,
// ember-app-scheduler properties
scrollWhenPainted: false,
scrollWhenIdle: false,

init(...args) {
this._super(...args);
this._loadConfig();
set(this, 'scrollMap', { default: { x: 0, y: 0 }});
set(this, 'scrollMap', {
default: {
x: 0, y: 0
}
});
},

unsetFirstLoad() {
Expand All @@ -47,7 +54,10 @@ const RouterScroll = Service.extend({

// if we are looking to where to transition to next, we need to set the default to the position
// of the targetElement on screen
set(scrollMap, 'default', { x, y });
set(scrollMap, 'default', {
x,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent formatting between this and line 26.

y
});
}
} else if ('window' === scrollElement) {
x = window.scrollX;
Expand All @@ -63,7 +73,10 @@ const RouterScroll = Service.extend({

// only a `key` present after first load
if (key && 'number' === typeOf(x) && 'number' === typeOf(y)) {
set(scrollMap, key, { x, y });
set(scrollMap, key, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, too.

x,
y
});
}
},

Expand All @@ -84,10 +97,14 @@ const RouterScroll = Service.extend({
set(this, 'targetElement', targetElement);
}

const delayScrollTop = config.routerScroll.delayScrollTop;
if (delayScrollTop === true) {
set(this, 'delayScrollTop', true);
}
const {
scrollWhenPainted = false,
scrollWhenIdle = false,
delayScrollTop = false
} = config.routerScroll;
set(this, 'delayScrollTop', delayScrollTop);
set(this, 'scrollWhenPainted', scrollWhenPainted);
set(this, 'scrollWhenIdle', scrollWhenIdle);
}
}
});
Expand Down
25 changes: 25 additions & 0 deletions addon/utils/scrollbar-width.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// https://stackoverflow.com/questions/13382516/getting-scroll-bar-width-using-javascript
export function getScrollBarWidth() {
let outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.width = '100px';
outer.style.msOverflowStyle = 'scrollbar';

document.body.appendChild(outer);

let widthNoScroll = outer.offsetWidth;
// force scrollbars
outer.style.overflow = 'scroll';

// add innerdiv
let inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);

let widthWithScroll = inner.offsetWidth;

// remove divs
outer.parentNode.removeChild(outer);

return widthNoScroll - widthWithScroll;
}
Loading