Implemented scrolling to active search result

This commit is contained in:
jos 2017-01-05 14:47:20 +01:00
parent 65e868b1c3
commit d5500bef89
6 changed files with 480 additions and 33 deletions

View File

@ -0,0 +1,204 @@
# Jump.js
[![Jump.js on NPM](https://img.shields.io/npm/v/jump.js.svg?style=flat-square)](https://www.npmjs.com/package/jump.js)
A small, modern, dependency-free smooth scrolling library.
* [Demo Page](http://callmecavs.github.io/jump.js/) (Click the arrows!)
## Usage
Jump was developed with a modern JavaScript workflow in mind. To use it, it's recommended you have a build system in place that can transpile ES6, and bundle modules. For a minimal boilerplate that fulfills those requirements, check out [outset](https://github.com/callmecavs/outset).
Follow these steps to get started:
1. [Install](#install)
2. [Import](#import)
3. [Call](#call)
4. [Review Options](#options)
### Install
Using NPM, install Jump, and save it to your `package.json` dependencies.
```bash
$ npm install jump.js --save
```
### Import
Import Jump, naming it according to your preference.
```es6
// import Jump
import jump from 'jump.js'
```
### Call
Jump exports a _singleton_, so there's no need to create an instance. Just call it, passing a [target](#target).
```es6
// call Jump, passing a target
jump('.target')
```
Note that the singleton can make an infinite number of jumps.
## Options
All options, **except [target](#target)**, are optional, and have sensible defaults. The defaults are shown below:
```es6
jump('.target', {
duration: 1000,
offset: 0,
callback: undefined,
easing: easeInOutQuad,
a11y: false
})
```
Explanation of each option follows:
* [target](#target)
* [duration](#duration)
* [offset](#offset)
* [callback](#callback)
* [easing](#easing)
* [a11y](#a11y)
### target
Scroll _from the current position_ by passing a number of pixels.
```es6
// scroll down 100px
jump(100)
// scroll up 100px
jump(-100)
```
Or, scroll _to an element_, by passing either:
* a node, or
* a CSS selector
```es6
// passing a node
const node = document.querySelector('.target')
jump(node)
// passing a CSS selector
// the element referenced by the selector is determined using document.querySelector
jump('.target')
```
### duration
Pass the time the `jump()` takes, in milliseconds.
```es6
jump('.target', {
duration: 1000
})
```
Or, pass a function that returns the duration of the `jump()` in milliseconds. This function is passed the `jump()` `distance`, in `px`, as a parameter.
```es6
jump('.target', {
duration: distance => Math.abs(distance)
})
```
### offset
Offset a `jump()`, _only if to an element_, by a number of pixels.
```es6
// stop 10px before the top of the element
jump('.target', {
offset: -10
})
// stop 10px after the top of the element
jump('.target', {
offset: 10
})
```
Note that this option is useful for accommodating `position: fixed` elements.
### callback
Pass a function that will be called after the `jump()` has been completed.
```es6
// in both regular and arrow functions, this === window
jump('.target', {
callback: () => console.log('Jump completed!')
})
```
### easing
Easing function used to transition the `jump()`.
```es6
jump('.target', {
easing: easeInOutQuad
})
```
See [easing.js](https://github.com/callmecavs/jump.js/blob/master/src/easing.js) for the definition of `easeInOutQuad`, the default easing function. Credit for this function goes to Robert Penner.
### a11y
If enabled, _and scrolling to an element_:
* add a [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) to, and
* [`focus`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus) the element
```es6
jump('.target', {
a11y: true
})
```
Note that this option is disabled by default because it has _visual implications_ in many browsers. Focusing an element triggers the `:focus` CSS state selector, and is often accompanied by an `outline`.
## Browser Support
Jump depends on the following browser APIs:
* [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame)
Consequently, it supports the following natively:
* Chrome 24+
* Firefox 23+
* Safari 6.1+
* Opera 15+
* IE 10+
* iOS Safari 7.1+
* Android Browser 4.4+
To add support for older browsers, consider including polyfills/shims for the APIs listed above. There are no plans to include any in the library, in the interest of file size.
## License
[MIT](https://opensource.org/licenses/MIT). © 2016 Michael Cavalea
[![Built With Love](http://forthebadge.com/images/badges/built-with-love.svg)](http://forthebadge.com)

View File

@ -0,0 +1,11 @@
// Robert Penner's easeInOutQuad
// find the rest of his easing functions here: http://robertpenner.com/easing/
// find them exported for ES6 consumption here: https://github.com/jaxgeller/ez.js
export default (t, b, c, d) => {
t /= d / 2
if(t < 1) return c / 2 * t * t + b
t--
return -c / 2 * (t * (t - 2) - 1) + b
}

View File

@ -0,0 +1,192 @@
import easeInOutQuad from './easing.js'
const jumper = () => {
// private variable cache
// no variables are created during a jump, preventing memory leaks
let container // container element to be scrolled (node)
let element // element to scroll to (node)
let start // where scroll starts (px)
let stop // where scroll stops (px)
let offset // adjustment from the stop position (px)
let easing // easing function (function)
let a11y // accessibility support flag (boolean)
let distance // distance of scroll (px)
let duration // scroll duration (ms)
let timeStart // time scroll started (ms)
let timeElapsed // time spent scrolling thus far (ms)
let next // next scroll position (px)
let callback // to call when done scrolling (function)
let scrolling // true whilst scrolling (boolean)
// scroll position helper
function location() {
return container.scrollY || container.pageYOffset || container.scrollTop
}
// element offset helper
function top(element) {
const elementTop = element.getBoundingClientRect().top
const containerTop = container.getBoundingClientRect
? container.getBoundingClientRect().top
: 0
return elementTop - containerTop + start
}
// scrollTo helper
function scrollTo(top) {
container.scrollTo
? container.scrollTo(0, top) // window
: container.scrollTop = top // custom container
}
// rAF loop helper
function loop(timeCurrent) {
// store time scroll started, if not started already
if(!timeStart) {
timeStart = timeCurrent
}
// determine time spent scrolling so far
timeElapsed = timeCurrent - timeStart
// calculate next scroll position
next = easing(timeElapsed, start, distance, duration)
// scroll to it
scrollTo(next)
scrolling = true
// check progress
timeElapsed < duration
? requestAnimationFrame(loop) // continue scroll loop
: done() // scrolling is done
}
// scroll finished helper
function done() {
// account for rAF time rounding inaccuracies
scrollTo(start + distance)
// if scrolling to an element, and accessibility is enabled
if(element && a11y) {
// add tabindex indicating programmatic focus
element.setAttribute('tabindex', '-1')
// focus the element
element.focus()
}
// if it exists, fire the callback
if(typeof callback === 'function') {
callback()
}
// reset time for next jump
timeStart = false
// we're done scrolling
scrolling = false
}
// API
function jump(target, options = {}) {
// resolve options, or use defaults
duration = options.duration || 1000
offset = options.offset || 0
callback = options.callback // "undefined" is a suitable default, and won't be called
easing = options.easing || easeInOutQuad
a11y = options.a11y || false
// resolve container
switch(typeof options.container) {
case 'object':
// we assume container is an HTML element (Node)
container = options.container
break
case 'string':
container = document.querySelector(options.container)
break
default:
container = window
}
// cache starting position
start = location()
// resolve target
switch(typeof target) {
// scroll from current position
case 'number':
element = undefined // no element to scroll to
a11y = false // make sure accessibility is off
stop = start + target
break
// scroll to element (node)
// bounding rect is relative to the viewport
case 'object':
element = target
stop = top(element)
break
// scroll to element (selector)
// bounding rect is relative to the viewport
case 'string':
element = document.querySelector(target)
stop = top(element)
break
}
// resolve scroll distance, accounting for offset
distance = stop - start + offset
// resolve duration
switch(typeof options.duration) {
// number in ms
case 'number':
duration = options.duration
break
// function passed the distance of the scroll
case 'function':
duration = options.duration(distance)
break
}
// start the loop if we're not already scrolling
if (!scrolling) {
requestAnimationFrame(loop)
}
else {
// reset time for next jump
timeStart = false
}
}
// expose only the jump method
return jump
}
// export singleton
const singleton = jumper()
export default singleton

View File

@ -1,13 +1,13 @@
// @flow weak
import { createElement as h, Component } from 'react'
//import { Element as ScrollElement } from 'react-scroll'
import ActionButton from './menu/ActionButton'
import AppendActionButton from './menu/AppendActionButton'
import { escapeHTML, unescapeHTML } from '../utils/stringUtils'
import { getInnerText, insideRect } from '../utils/domUtils'
import { stringConvert, valueType, isUrl } from '../utils/typeUtils'
import { compileJSONPointer } from '../jsonData'
import type { PropertyData, JSONData, SearchResultStatus } from '../types'
@ -41,7 +41,7 @@ export default class JSONNode extends Component {
renderJSONObject ({prop, index, data, options, events}) {
const childCount = data.props.length
const node = h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-object'}, [
const node = h('div', {name: compileJSONPointer(this.getPath()), key: 'node', className: 'jsoneditor-node jsoneditor-object'}, [
this.renderExpandButton(),
this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options),
@ -80,7 +80,7 @@ export default class JSONNode extends Component {
renderJSONArray ({prop, index, data, options, events}) {
const childCount = data.items.length
const node = h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-array'}, [
const node = h('div', {name: compileJSONPointer(this.getPath()), key: 'node', className: 'jsoneditor-node jsoneditor-array'}, [
this.renderExpandButton(),
this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options),
@ -117,7 +117,7 @@ export default class JSONNode extends Component {
}
renderJSONValue ({prop, index, data, options}) {
return h('div', {className: 'jsoneditor-node'}, [
return h('div', {name: compileJSONPointer(this.getPath()), className: 'jsoneditor-node'}, [
this.renderPlaceholder(),
this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options),

View File

@ -1,13 +1,15 @@
import { createElement as h, Component } from 'react'
import jump from '../assets/jump.js/src/jump'
import Ajv from 'ajv'
import { updateIn, getIn, setIn } from '../utils/immutabilityHelpers'
import { parseJSON } from '../utils/jsonUtils'
import { enrichSchemaError } from '../utils/schemaUtils'
import {
jsonToData, dataToJson, toDataPath, patchData, pathExists,
expand, expandPath, addErrors,
search, addSearchResults, nextSearchResult, previousSearchResult
search, addSearchResults, nextSearchResult, previousSearchResult,
compileJSONPointer
} from '../jsonData'
import {
duplicate, insert, append, remove,
@ -34,6 +36,8 @@ export default class TreeMode extends Component {
const data = jsonToData(this.props.data || {}, TreeMode.expandAll, [])
this.id = Math.round(Math.random() * 1e5) // TODO: create a uuid here?
this.state = {
data,
@ -131,7 +135,12 @@ export default class TreeMode extends Component {
}, [
this.renderMenu(searchResults ? searchResults.length : null),
h('div', {key: 'contents', className: 'jsoneditor-contents jsoneditor-tree-contents', onClick: this.handleHideMenus},
h('div', {
key: 'contents',
ref: 'contents',
className: 'jsoneditor-contents jsoneditor-tree-contents',
onClick: this.handleHideMenus, id: this.id
},
h('ul', {className: 'jsoneditor-list jsoneditor-root'},
h(Node, {
data,
@ -316,14 +325,23 @@ export default class TreeMode extends Component {
/** @private */
handleSearch = (text) => {
const searchResults = search(this.state.data, text)
const active = searchResults[0] || null
this.setState({
search: { text, active },
data: expandPath(this.state.data,active && active.path)
})
if (searchResults.length > 0) {
const active = searchResults[0]
// TODO: scroll to the active result
this.setState({
search: { text, active },
data: expandPath(this.state.data, active.path)
})
// scroll to active search result
this.scrollTo(active.path)
}
else {
this.setState({
search: { text, active: null }
})
}
}
/** @private */
@ -337,7 +355,11 @@ export default class TreeMode extends Component {
data: expandPath(this.state.data, next && next.path)
})
// TODO: scroll to the active result
// scroll to the active result
const name = compileJSONPointer(next.path)
// scroll to the active result
this.scrollTo(next.path)
}
}
@ -352,7 +374,8 @@ export default class TreeMode extends Component {
data: expandPath(this.state.data, previous && previous.path)
})
// TODO: scroll to the active result
// scroll to the active result
this.scrollTo(previous.path)
}
}
@ -368,6 +391,21 @@ export default class TreeMode extends Component {
this.emitOnChange (actions, result.revert, result.data)
}
/**
* Scroll the window vertically to the node with given path
* @param {Path} path
* @private
*/
scrollTo = (path) => {
const name = compileJSONPointer(path)
const container = this.refs.contents
const elem = container.querySelector('div[name="' + name + '"]')
if (elem) {
jump(elem, { container, offset: -100, duration: 400 })
}
}
/**
* Emit an onChange event when there is a listener for it.
* @param {JSONPatch} patch

View File

@ -540,28 +540,30 @@ export function addErrors (data, errors) {
export function search (data: JSONData, text: string): DataPointer[] {
let results: DataPointer[] = []
traverse(data, function (value, path) {
// check property name
if (path.length > 0) {
const prop = last(path)
if (containsCaseInsensitive(prop, text)) {
// only add search result when this is an object property name,
// don't add search result for array indices
const parentPath = allButLast(path)
const parent = getIn(data, toDataPath(data, parentPath))
if (parent.type === 'Object') {
results.push({ path, type: 'property' })
if (text !== '') {
traverse(data, function (value, path) {
// check property name
if (path.length > 0) {
const prop = last(path)
if (containsCaseInsensitive(prop, text)) {
// only add search result when this is an object property name,
// don't add search result for array indices
const parentPath = allButLast(path)
const parent = getIn(data, toDataPath(data, parentPath))
if (parent.type === 'Object') {
results.push({path, type: 'property'})
}
}
}
}
// check value
if (value.type === 'value') {
if (containsCaseInsensitive(value.value, text)) {
results.push({ path, type: 'value' })
// check value
if (value.type === 'value') {
if (containsCaseInsensitive(value.value, text)) {
results.push({path, type: 'value'})
}
}
}
})
})
}
return results
}