Implement scroll to active element via querySelector (WIP)

This commit is contained in:
josdejong 2020-05-24 11:57:51 +02:00
parent d04c94a1c6
commit d8c059c9b0
10 changed files with 483 additions and 15 deletions

5
package-lock.json generated
View File

@ -752,6 +752,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
}, },
"lodash-es": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ=="
},
"log-symbols": { "log-symbols": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz",

View File

@ -13,7 +13,7 @@
"@fortawesome/free-regular-svg-icons": "5.13.0", "@fortawesome/free-regular-svg-icons": "5.13.0",
"@fortawesome/free-solid-svg-icons": "5.13.0", "@fortawesome/free-solid-svg-icons": "5.13.0",
"classnames": "2.2.6", "classnames": "2.2.6",
"lodash": "4.17.15", "lodash-es": "4.17.15",
"svelte-awesome": "2.3.0" "svelte-awesome": "2.3.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -29,7 +29,7 @@
color: purple; color: purple;
} }
#testEditor { #testEditorContainer {
width: 800px; width: 800px;
height: 500px; height: 500px;
max-width: 100%; max-width: 100%;
@ -38,7 +38,7 @@
</head> </head>
<body> <body>
<div id="testEditor"></div> <div id="testEditorContainer"></div>
<p> <p>
<button id="loadLargeJson">load large json</button> <button id="loadLargeJson">load large json</button>
<button id="clearJson">clear json</button> <button id="clearJson">clear json</button>
@ -85,12 +85,13 @@
} }
const testEditor = jsoneditor({ const testEditor = jsoneditor({
target: document.getElementById('testEditor'), target: document.getElementById('testEditorContainer'),
props: { props: {
json, json,
onChangeJson: json => console.log('onChangeJson', json) onChangeJson: json => console.log('onChangeJson', json)
} }
}) })
window.testEditor = testEditor // expose to window for debugging
document.getElementById('loadLargeJson').onclick = function handleLoadLargeJson() { document.getElementById('loadLargeJson').onclick = function handleLoadLargeJson() {
const count = 500 const count = 500

View File

@ -1,25 +1,32 @@
<script> <script>
import { EXPANDED_PROPERTY } from './constants.js' import { tick } from 'svelte'
import { EXPANDED_PROPERTY, SCROLL_DURATION } from './constants.js'
import SearchBox from './SearchBox.svelte' import SearchBox from './SearchBox.svelte'
import Icon from 'svelte-awesome' import Icon from 'svelte-awesome'
import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons' import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
import { createHistory } from './history.js' import { createHistory } from './history.js'
import Node from './JSONNode.svelte' import Node from './JSONNode.svelte'
import { existsIn, setIn } from './utils/immutabilityHelpers.js' import { existsIn, setIn } from './utils/immutabilityHelpers.js'
import { compileJSONPointer } from './utils/jsonPointer.js'
import { keyComboFromEvent } from './utils/keyBindings.js' import { keyComboFromEvent } from './utils/keyBindings.js'
import { flattenSearch, search } from './utils/search.js' import { flattenSearch, search } from './utils/search.js'
import { immutableJSONPatch } from './utils/immutableJSONPatch' import { immutableJSONPatch } from './utils/immutableJSONPatch'
import { isEqual } from 'lodash' import { isEqual } from 'lodash-es'
import jump from './assets/jump.js/src/jump.js'
let divContents
export let json = {} export let json = {}
export let onChangeJson = () => { export let onChangeJson = () => {
} }
let state = { const INITIAL_STATE = {
[EXPANDED_PROPERTY]: true [EXPANDED_PROPERTY]: true
} }
let showSearch = true // FIXME: change to false let state = INITIAL_STATE
let showSearch = false
let searchText = '' let searchText = ''
const history = createHistory({ const history = createHistory({
@ -35,6 +42,7 @@
export function set(newJson) { export function set(newJson) {
json = newJson json = newJson
state = INITIAL_STATE
history.clear() history.clear()
} }
@ -72,13 +80,10 @@
} }
function doSearch(json, searchText) { function doSearch(json, searchText) {
console.time('search') return search(null, json, searchText)
const result = search(null, json, searchText)
console.timeEnd('search')
return result
} }
// TODO: refactor the search solution, it's too complex. Also, move it in a separate component // TODO: refactor the search solution and move it in a separate component
let searchResult let searchResult
let activeSearchResult = undefined let activeSearchResult = undefined
let activeSearchResultIndex let activeSearchResultIndex
@ -90,6 +95,7 @@
$: { $: {
if (!activeSearchResult || !existsIn(searchResult, activeSearchResult.path.concat(activeSearchResult.what))) { if (!activeSearchResult || !existsIn(searchResult, activeSearchResult.path.concat(activeSearchResult.what))) {
activeSearchResult = flatSearchResult[0] activeSearchResult = flatSearchResult[0]
focusActiveSearchResult()
} }
} }
@ -102,12 +108,42 @@
function nextSearchResult () { function nextSearchResult () {
activeSearchResult = flatSearchResult[activeSearchResultIndex + 1] || activeSearchResult activeSearchResult = flatSearchResult[activeSearchResultIndex + 1] || activeSearchResult
focusActiveSearchResult()
} }
function previousSearchResult () { function previousSearchResult () {
activeSearchResult = flatSearchResult[activeSearchResultIndex - 1] || activeSearchResult activeSearchResult = flatSearchResult[activeSearchResultIndex - 1] || activeSearchResult
focusActiveSearchResult()
} }
async function focusActiveSearchResult () {
if (activeSearchResult) {
expandPath(activeSearchResult.path)
await tick()
scrollTo(activeSearchResult.path.concat(activeSearchResult.what))
}
}
/**
* Scroll the window vertically to the node with given path
* @param {Path} path
*/
function scrollTo (path) {
const elem = divContents.querySelector(`div[data-path="${compileJSONPointer(path)}"]`)
const offset = -(divContents.getBoundingClientRect().height / 4)
if (elem) {
jump(elem, {
container: divContents,
offset,
duration: SCROLL_DURATION
})
}
}
function handleChangeKey(key, oldKey) { function handleChangeKey(key, oldKey) {
// console.log('handleChangeKey', { key, oldKey }) // console.log('handleChangeKey', { key, oldKey })
// TODO: this should not happen? // TODO: this should not happen?
@ -162,6 +198,16 @@
state = setIn(state, path.concat(EXPANDED_PROPERTY), expanded) state = setIn(state, path.concat(EXPANDED_PROPERTY), expanded)
} }
/**
* Expand all nodes on given path
* @param {Path} path
*/
function expandPath (path) {
for (let i = 1; i < path.length; i++) {
state = setIn(state, path.slice(0, i).concat(EXPANDED_PROPERTY), true)
}
}
function handleKeyDown (event) { function handleKeyDown (event) {
const combo = keyComboFromEvent(event) const combo = keyComboFromEvent(event)
@ -248,7 +294,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="contents"> <div class="contents" bind:this={divContents}>
<Node <Node
value={json} value={json}
state={state} state={state}

View File

@ -233,6 +233,7 @@
{#if typeof key === 'string'} {#if typeof key === 'string'}
<div <div
class={keyClass} class={keyClass}
data-path={compileJSONPointer(getPath().concat(SEARCH_PROPERTY))}
contenteditable="true" contenteditable="true"
spellcheck="false" spellcheck="false"
on:input={handleKeyInput} on:input={handleKeyInput}
@ -285,6 +286,7 @@
{#if typeof key === 'string'} {#if typeof key === 'string'}
<div <div
class={keyClass} class={keyClass}
data-path={compileJSONPointer(getPath().concat(SEARCH_PROPERTY))}
contenteditable="true" contenteditable="true"
spellcheck="false" spellcheck="false"
on:input={handleKeyInput} on:input={handleKeyInput}
@ -325,6 +327,7 @@
{#if typeof key === 'string'} {#if typeof key === 'string'}
<div <div
class={keyClass} class={keyClass}
data-path={compileJSONPointer(getPath().concat(SEARCH_PROPERTY))}
contenteditable="true" contenteditable="true"
spellcheck="false" spellcheck="false"
on:input={handleKeyInput} on:input={handleKeyInput}
@ -335,6 +338,7 @@
{/if} {/if}
<div <div
class={valueClass} class={valueClass}
data-path={compileJSONPointer(getPath().concat(SEARCH_VALUE))}
contenteditable="true" contenteditable="true"
spellcheck="false" spellcheck="false"
on:input={handleValueInput} on:input={handleValueInput}

View File

@ -1,5 +1,5 @@
<script> <script>
import { debounce } from 'lodash' import { debounce } from 'lodash-es'
import Icon from 'svelte-awesome' import Icon from 'svelte-awesome'
import { faSearch, faChevronDown, faChevronUp, faTimes } from '@fortawesome/free-solid-svg-icons' import { faSearch, faChevronDown, faChevronUp, faTimes } from '@fortawesome/free-solid-svg-icons'
import { keyComboFromEvent } from './utils/keyBindings.js' import { keyComboFromEvent } from './utils/keyBindings.js'

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,196 @@
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
default:
}
// 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
default:
}
// 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

@ -3,3 +3,4 @@ export const EXPANDED_PROPERTY = '$jse:expanded'
export const SEARCH_PROPERTY = '$jse:search:property' export const SEARCH_PROPERTY = '$jse:search:property'
export const SEARCH_VALUE = '$jse:search:value' export const SEARCH_VALUE = '$jse:search:value'
export const SCROLL_DURATION = 300 // ms