From d5500bef89cc0953e43bb64e0793d5ed50c1ef66 Mon Sep 17 00:00:00 2001 From: jos Date: Thu, 5 Jan 2017 14:47:20 +0100 Subject: [PATCH] Implemented scrolling to active search result --- src/assets/jump.js/README.md | 204 +++++++++++++++++++++++++++++++ src/assets/jump.js/src/easing.js | 11 ++ src/assets/jump.js/src/jump.js | 192 +++++++++++++++++++++++++++++ src/components/JSONNode.js | 8 +- src/components/TreeMode.js | 60 +++++++-- src/jsonData.js | 38 +++--- 6 files changed, 480 insertions(+), 33 deletions(-) create mode 100644 src/assets/jump.js/README.md create mode 100644 src/assets/jump.js/src/easing.js create mode 100644 src/assets/jump.js/src/jump.js diff --git a/src/assets/jump.js/README.md b/src/assets/jump.js/README.md new file mode 100644 index 0000000..78c03bc --- /dev/null +++ b/src/assets/jump.js/README.md @@ -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) diff --git a/src/assets/jump.js/src/easing.js b/src/assets/jump.js/src/easing.js new file mode 100644 index 0000000..e4b1fcc --- /dev/null +++ b/src/assets/jump.js/src/easing.js @@ -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 +} diff --git a/src/assets/jump.js/src/jump.js b/src/assets/jump.js/src/jump.js new file mode 100644 index 0000000..63b8e8f --- /dev/null +++ b/src/assets/jump.js/src/jump.js @@ -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 diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index 09d06eb..00f9177 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -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), diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index 3991a97..18fab0d 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -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 diff --git a/src/jsonData.js b/src/jsonData.js index 3f165d7..d75cbdf 100644 --- a/src/jsonData.js +++ b/src/jsonData.js @@ -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 }