Commit f72ec7cf authored by Jan Kuchař's avatar Jan Kuchař
Browse files

Merge branch 'better-build' into 'master'

Minor rework

See merge request !5
parents d2709da9 01d53ac1
Pipeline #14014 passed with stages
in 58 seconds
{
"presets": [
[
"env",
"@babel/env",
{
"targets": {
"browsers": [
"last 2 versions",
"IE >= 11"
]
},
"useBuiltIns": true
}
}
]
]
......
.idea
node_modules
.idea/
.rpt2_cache/
dist/
node_modules/
visual.dist.*
yarn.lock
image: node:10
stages:
- install
- build
- publish
install:
stage: install
script: yarn
artifacts:
paths:
- node_modules/
expire_in: 2 hours
build:
stage: build
dependencies: [install]
script: yarn build
artifacts:
paths:
- dist/
expire_in: 2 hours
publish:
stage: publish
only: [tags]
dependencies: [build]
script: echo "//registry.npmjs.org/:_authToken=${NPM_ACCESS_TOKEN}" > .npmrc && npm publish --access public
artifacts:
paths:
- dist/
.idea/
.rpt2_cache/
.babelrc
demo.html
rollup.config.js
tsconfig.json
yarn.lock
# grifart/smoothscroll
# @grifart/smoothscroll
As smoothscroll functionality is nice and more user-friendly, this library solves these two things which were often repeating in our code:
......@@ -8,13 +8,19 @@ As smoothscroll functionality is nice and more user-friendly, this library solve
Additionally this library comes with custom easing function registered by default which works as basic ease-in-out with one modification that it skips content if it is too long. This results in nicer transition between two parts of a page.
## Usage
## Installation and usage
Import library using ES6 `import` statement and call the smoothscroll function.
It accepts one optional parameter - an `options` object, which allows you to turn on/off some behaviour.
```bash
yarn add @grifart/smoothscroll
```
```javascript
SmoothScroll([options])
import SmoothScroll from '@grifart/smoothscroll';
SmoothScroll({
load: true,
interaction: true,
});
```
### Options
......@@ -24,29 +30,13 @@ SmoothScroll([options])
| `load` | `true`/`false` | `true` | Causes smooth scroll to anchored element when the page is loaded.\*
| `interaction` | `true`/`false` | `true` | Causes smooth scroll on given element when user clicks on an `a` tag having `href` starting with `#` character.
\*Note: when the page load lasts more than 500 ms, load smooth scrolling is disable as it would lead to user-unfriendly behaviour like jumping on the page up and down.
Usage example with all options passed:
```javascript
SmoothScroll({
load: true,
interaction: true,
});
```
\*Note: when the page load lasts more than 500 ms, load smooth scrolling is disabled as it would lead to user-unfriendly behaviour like jumping on the page up and down.
## Development
Whole library consists only of one file - `index.js`.
If you need to check visually how the smooth scrolling behaviour acts like, you can take advantage of a testing file `visual.html` which has some lorem ipsum data and few of links to navigate through the content and test smooth scrolling.
To get it work, you need to run build first with following commands:
```bash
yarn install
yarn run gulp
yarn dev
```
You can use `yarn run gulp watch` as well.
If you need to check visually how the smooth scrolling behaviour acts like, you can take advantage of a testing file `demo.html` which has some lorem ipsum data and few of links to navigate through the content and test smooth scrolling.
......@@ -4,7 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<title>Visual test of smoothscroll</title>
<script src="visual.dist.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/velocity-animate@1.5/velocity.min.js"></script>
<script src="dist/index.js" type="text/javascript"></script>
<script>
SmoothScroll({
load: true,
interaction: true,
});
</script>
<style type="text/css">
body {
margin: 0 auto;
......
'use strict';
import babelify from 'babelify';
import browserify from 'browserify';
import buffer from 'vinyl-buffer';
import gulp from 'gulp';
import plumber from 'gulp-plumber';
import rename from 'gulp-rename';
import source from 'vinyl-source-stream';
import sourcemaps from 'gulp-sourcemaps';
// BUILD
gulp.task('default', ['build']);
gulp.task('build', (cb) => {
buildJs(cb);
});
gulp.task('watch', ['build'], () => {
gulp.watch([
'index.js',
'visual.js',
], ['build']);
});
const buildJs = (cb) => {
const smoothScrollingFileName = 'visual.js';
let bundler = browserify(smoothScrollingFileName, {debug: true});
bundler.transform(babelify, {
presets: ['env'],
sourceMaps: true
});
bundler.bundle()
.pipe(plumber())
.pipe(source(smoothScrollingFileName))
.pipe(rename('visual.dist.js'))
.pipe(buffer())
.pipe(sourcemaps.init({loadMaps: true}))
.pipe(sourcemaps.write('.'))
.pipe(plumber.stop())
.pipe(gulp.dest('.'))
.on('end', () => {
cb();
});
};
import Velocity from 'velocity-animate';
export default function (options) {
((windowObject, documentObject, options) => {
/**
* This function scrolls smoothly to an element if there is a hash in the URL.
* E.g. you have `#example` in URL, then it scrolls to element with id `example`.
*
* Note:
* Because of UX, this behaviour is limited only when whole document is loaded in less than 500ms.
* Otherwise, it jumps directly to desired element without smooth scrolling, because too visible jumping through the page would appear.
*/
const handleLoad = () => {
if (typeof document.querySelector === 'undefined') {
return;
}
if (typeof document.addEventListener === 'undefined') {
return;
}
if (typeof windowObject.location === 'undefined') {
return;
}
// If no hash, we do not need to run scrolling.
if (!windowObject.location.hash) {
return;
}
// If performance is not present, the browser would not scroll smoothly otherwise I guess. So let's skip it completely, it's not worth fallbacking to Date() function.
if (typeof performance === 'undefined') {
return;
}
// Start timer.
const start = performance.now();
/*
* The `load` event has been chosen intentionally as it is the state when everything is ready -
* all styles are loaded and offsets are computed correctly - so the scroll will be computed correctly.
*/
windowObject.addEventListener('load', () => {
// End timer.
const end = performance.now();
// If difference between start and stop is greater than 500ms, do nothing.
if (end - start > 500) {
return;
}
// First, we need to go to top immediately (hack to prevent jump to desired element).
windowObject.scroll({top: 0, left: 0});
// Then, scroll down to it smoothly.
scrollTo(windowObject.location.hash, documentObject, windowObject);
});
};
const handleInteraction = () => {
if ((typeof document.querySelector === 'undefined') || (typeof document.querySelectorAll === 'undefined')) {
return;
}
if (typeof document.addEventListener === 'undefined') {
return;
}
documentObject.addEventListener('DOMContentLoaded', () => {
const items = documentObject.querySelectorAll('a[href^="#"]');
for (let i in items) {
if (typeof items[i] !== 'object') {
continue;
}
items[i].addEventListener('click', (e) => {
if (typeof e.currentTarget.getAttribute === 'undefined') {
return;
}
const hash = e.currentTarget.getAttribute('href');
if (!hash) {
return;
}
e.preventDefault();
e.stopPropagation();
scrollTo(hash, documentObject, windowObject);
});
}
});
};
// Registration of custom easing
Velocity.Easings['ease-in-skip-out'] = easingWithSkip(windowObject);
// If `options.interaction` is not explicitly set to `false`, run handler.
if (!(options !== undefined && typeof options === 'object' && options.interaction !== undefined && options.interaction === false)) {
handleInteraction();
}
// If `options.load` is not explicitly set to `false`, run handler.
if (!(options !== undefined && typeof options === 'object' && options.load !== undefined && options.load === false)) {
handleLoad();
}
})(window, document, options);
};
const scrollTo = (hash, documentObject, windowObject) => {
const element = documentObject.querySelector(hash);
Velocity(element, 'scroll', {
duration: 1200, // @todo: different depending on offset from page top?
easing: 'ease-in-skip-out',
complete: () => {
windowObject.location.hash = hash;
}
});
};
//Code for custom easing and helpers for custom easing
// e.g. (5, 5, 10, 500, 1000) => 500
// e.g. (5, 0, 10, 500, 1000) => 750
const mapIntervalLinear = (number, originalFrom, originalTo, newFrom, newTo) => {
const oldDistance = originalTo - originalFrom;
const newDistance = newTo - newFrom;
// normalize value into interval 0 .. 1
const normalized = (number - originalFrom) / oldDistance;
// extend and move normalized value into new interval
return (normalized * newDistance) + newFrom;
};
/**
* Composes easings together, splits time into half.
*
* @param firstHalfEasingFn first half of easing
* @param secondHalfEasingFn second half of easing
* @return {function(*=, *=, *=)} the composed easing
*/
const compositeEasing = (firstHalfEasingFn, secondHalfEasingFn) => {
// time: The call's completion percentage (decimal value).
// opts (optional): The options object passed into the triggering Velocity call.
// tweenDelta (optional): The difference between the animating property's ending value and its starting value.
return (time, opts, tweenDelta) => {
if(time < 0.5) {
const normalizedTime = mapIntervalLinear(time, 0, 0.5, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(firstHalfEasingFn(normalizedTime, opts, tweenDelta), 0, 1, 0, 0.5); // map 1 - 0 => 0 - 0.5
} else {
const normalizedTime = mapIntervalLinear(time, 0.5, 1, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(secondHalfEasingFn(normalizedTime, opts, tweenDelta), 0, 1, 0.5, 1); // map 1 - 0 => 0 - 0.5
}
}
};
const computeHowMuchToSkip = (windowObject, tweenDelta) => {
const howManyScreens = Math.abs(tweenDelta) / windowObject.innerHeight;
// 0 .. 1 (percents)
let howMuchToSkip = 0;
// by testing in browser we have found following values as smooth:
// howManyScreens .. howMuchToSkip
// 1 .. 0 %
// 2 .. 0 %
// 3 .. 30 %
// 8 .. 60 %
// 30 .. 85 %
// 60 .. 90 %
// 100 .. 90 %
if (howManyScreens <= 2) {
howMuchToSkip = 0;
} else if (howManyScreens <= 4) { // 2 - 4 screens; skip 0% - 30% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 2, 4, 0, 0.3);
} else if (howManyScreens <= 8) { // 4 - 8 screens; skip 30% - 60% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 4, 8, 0.3, 0.6);
} else if (howManyScreens <= 30) { // 8 - 30 screens; skip 60% - 85% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 8, 30, 0.6, 0.85);
} else if (howManyScreens <= 60) { // 30 - 60 screens; skip 85% - 90% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 30, 60, 0.85, 0.9);
} else { // > 60 screens; skip 90% of content
howMuchToSkip = 0.9;
}
return howMuchToSkip;
};
const easingWithSkip = (windowObject) => {
return compositeEasing(
(time, opts, tweenDelta) => mapIntervalLinear(
Velocity.Easings["ease-in"](time, opts, tweenDelta),
0, 1, // from interval
0, 1 - computeHowMuchToSkip(windowObject, tweenDelta) // to interval
),
(time, opts, tweenDelta) => mapIntervalLinear(
Velocity.Easings["ease-out"](time, opts, tweenDelta),
0, 1, // from interval
computeHowMuchToSkip(windowObject, tweenDelta), 1 // to interval
)
);
};
{
"name": "@grifart/smoothscroll",
"version": "0.4.0",
"main": "index.js",
"private": true,
"version": "0.5.0",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"dependencies": {
"velocity-animate": "^1.5.1"
},
"devDependencies": {
"babel-core": "^6.26.3",
"babel-preset-env": "^1.7.0",
"babelify": "^8.0.0",
"browserify": "^16.2.2",
"gulp": "^3.9.1",
"gulp-plumber": "^1.2.0",
"gulp-rename": "^1.2.2",
"gulp-sourcemaps": "^2.6.4",
"velocity-animate": "^1.5.1",
"vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0"
"@babel/core": "^7.2.0",
"@babel/preset-env": "^7.2.0",
"@types/velocity-animate": "^1.2.33",
"rollup": "^0.67.4",
"rollup-plugin-babel": "^4.0.3",
"rollup-plugin-node-resolve": "^4.0.0",
"rollup-plugin-typescript2": "^0.18.1",
"typescript": "^3.2.2"
},
"browserify": {
"transform": [
"babelify"
]
"scripts": {
"build": "rollup -c",
"dev": "rollup -wc"
}
}
import babel from 'rollup-plugin-babel';
import resolve from 'rollup-plugin-node-resolve';
import typescript from 'rollup-plugin-typescript2';
const pkg = require('./package.json');
export default {
input: 'src/index.ts',
output: [
{ file: pkg.main, format: 'umd', name: 'SmoothScroll', globals: { 'velocity-animate': 'Velocity' }, sourceMap: true },
{ file: pkg.module, format: 'esm', sourceMap: true },
],
external: [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
],
plugins: [
resolve(),
typescript(),
babel({
exclude: 'node_modules/**',
}),
],
}
import * as Velocity from 'velocity-animate';
import setupVelocity from './setupVelocity';
interface SmoothScrollOptions {
readonly load?: boolean;
readonly interaction?: boolean;
}
const SmoothScroll = (options?: SmoothScrollOptions) => {
setupVelocity(Velocity);
if ( ! (options && options.load === false)) {
handleLoad();
}
if ( ! (options && options.interaction === false)) {
handleInteraction();
}
};
export default SmoothScroll;
/**
* This function scrolls smoothly to an element if there is a hash in the URL.
* E.g. you have `#example` in URL, then it scrolls to element with id `example`.
*
* Note:
* Because of UX, this behaviour is limited only when whole document is loaded in less than 500ms.
* Otherwise, it jumps directly to desired element without smooth scrolling, because too visible jumping through the page would appear.
*/
const handleLoad = () => {
// If no hash, we do not need to run scrolling.
if ( ! window.location.hash) {
return;
}
// If performance is not present, the browser would not scroll smoothly otherwise I guess. So let's skip it completely, it's not worth fallbacking to Date() function.
if (performance === undefined) {
return;
}
const start = performance.now();
/*
* The `load` event has been chosen intentionally as it is the state when everything is ready -
* all styles are loaded and offsets are computed correctly - so the scroll will be computed correctly.
*/
window.addEventListener('load', () => {
const end = performance.now();
// If difference between start and stop is greater than 500ms, do nothing.
if (end - start > 500) {
return;
}
// First, we need to go to top immediately (hack to prevent jump to desired element).
window.scroll({top: 0, left: 0});
// Then, scroll down to it smoothly.
scrollTo(window.location.hash);
});
};
const handleInteraction = () => {
document.addEventListener('DOMContentLoaded', () => {
const items = document.querySelectorAll('a[href^="#"]');
for (const i in items) {
if ( ! (items as object).hasOwnProperty(i)) {
continue;
}
items[i].addEventListener('click', (e) => {
const element = e.currentTarget as HTMLAnchorElement;
if ( ! element.hash) {
return;
}
e.preventDefault();
e.stopPropagation();
scrollTo(element.hash);
});
}
});
};
const scrollTo = (hash: string) => {
const element = document.getElementById(hash.substring(1));
if (element === null) {
return;
}
Velocity.animate(element, 'scroll', {
duration: 1200, // todo: different depending on offset from page top?
easing: 'ease-in-skip-out',
complete: () => window.location.hash = hash,
});
};
import * as Velocity from 'velocity-animate';
declare const setupVelocity: (velocity: typeof Velocity) => void;
export default setupVelocity;
// e.g. (5, 5, 10, 500, 1000) => 500
// e.g. (5, 0, 10, 500, 1000) => 750
const mapIntervalLinear = (number, originalFrom, originalTo, newFrom, newTo) => {
const oldDistance = originalTo - originalFrom;
const newDistance = newTo - newFrom;
// normalize value into interval 0 .. 1
const normalized = (number - originalFrom) / oldDistance;
// extend and move normalized value into new interval
return (normalized * newDistance) + newFrom;
};
/**
* Composes easings together, splits time into half.
*
* @param firstHalfEasingFn first half of easing
* @param secondHalfEasingFn second half of easing
* @return {function(*=, *=, *=)} the composed easing
*/
const composeEasing = (firstHalfEasingFn, secondHalfEasingFn) => {
// time: The call's completion percentage (decimal value).
// opts (optional): The options object passed into the triggering Velocity call.
// tweenDelta (optional): The difference between the animating property's ending value and its starting value.
return (time, opts, tweenDelta) => {
if (time < 0.5) {
const normalizedTime = mapIntervalLinear(time, 0, 0.5, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(firstHalfEasingFn(normalizedTime, opts, tweenDelta), 0, 1, 0, 0.5); // map 1 - 0 => 0 - 0.5
} else {
const normalizedTime = mapIntervalLinear(time, 0.5, 1, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(secondHalfEasingFn(normalizedTime, opts, tweenDelta), 0, 1, 0.5, 1); // map 1 - 0 => 0 - 0.5
}
}
};