jsoneditor
Version:
A web-based tool to view, edit, format, and validate JSON
380 lines (334 loc) • 14.1 kB
JavaScript
'use strict'
const defaultFilterFunction = {
start: function (token, match, config) {
return match.indexOf(token) === 0
},
contain: function (token, match, config) {
return match.indexOf(token) > -1
}
}
export function autocomplete (config) {
config = config || {}
config.filter = config.filter || 'start'
config.trigger = config.trigger || 'keydown'
config.confirmKeys = config.confirmKeys || [39, 35, 9] // right, end, tab
config.caseSensitive = config.caseSensitive || false // autocomplete case sensitive
let fontSize = ''
let fontFamily = ''
const wrapper = document.createElement('div')
wrapper.style.position = 'relative'
wrapper.style.outline = '0'
wrapper.style.border = '0'
wrapper.style.margin = '0'
wrapper.style.padding = '0'
const dropDown = document.createElement('div')
dropDown.className = 'autocomplete dropdown'
dropDown.style.position = 'absolute'
dropDown.style.visibility = 'hidden'
let spacer
let leftSide // <-- it will contain the leftSide part of the textfield (the bit that was already autocompleted)
const createDropDownController = (elem, rs) => {
let rows = []
let ix = 0
let oldIndex = -1
// TODO: move this styling in JS to SCSS
const onMouseOver = function () { this.style.backgroundColor = '#ddd' }
const onMouseOut = function () { this.style.backgroundColor = '' }
const onMouseDown = function () { p.hide(); p.onmouseselection(this.__hint, p.rs) }
const p = {
rs,
hide: function () {
elem.style.visibility = 'hidden'
// rs.hideDropDown();
},
refresh: function (token, array) {
elem.style.visibility = 'hidden'
ix = 0
elem.textContent = ''
const vph = (window.innerHeight || document.documentElement.clientHeight)
const rect = elem.parentNode.getBoundingClientRect()
const distanceToTop = rect.top - 6 // heuristic give 6px
const distanceToBottom = vph - rect.bottom - 6 // distance from the browser border.
rows = []
const filterFn = typeof config.filter === 'function' ? config.filter : defaultFilterFunction[config.filter]
const filtered = !filterFn ? [] : array.filter(match => filterFn(config.caseSensitive ? token : token.toLowerCase(), config.caseSensitive ? match : match.toLowerCase(), config))
rows = filtered.map(row => {
const divRow = document.createElement('div')
divRow.className = 'item'
// divRow.style.color = config.color;
divRow.onmouseover = onMouseOver
divRow.onmouseout = onMouseOut
divRow.onmousedown = onMouseDown
divRow.__hint = row
divRow.textContent = ''
divRow.appendChild(document.createTextNode(row.substring(0, token.length)))
const b = document.createElement('b')
b.appendChild(document.createTextNode(row.substring(token.length)))
divRow.appendChild(b)
elem.appendChild(divRow)
return divRow
})
if (rows.length === 0) {
return // nothing to show.
}
if (rows.length === 1 && ((token.toLowerCase() === rows[0].__hint.toLowerCase() && !config.caseSensitive) ||
(token === rows[0].__hint && config.caseSensitive))) {
return // do not show the dropDown if it has only one element which matches what we have just displayed.
}
if (rows.length < 2) return
p.highlight(0)
if (distanceToTop > distanceToBottom * 3) { // Heuristic (only when the distance to the to top is 4 times more than distance to the bottom
elem.style.maxHeight = distanceToTop + 'px' // we display the dropDown on the top of the input text
elem.style.top = ''
elem.style.bottom = '100%'
} else {
elem.style.top = '100%'
elem.style.bottom = ''
elem.style.maxHeight = distanceToBottom + 'px'
}
elem.style.visibility = 'visible'
},
highlight: function (index) {
if (oldIndex !== -1 && rows[oldIndex]) {
rows[oldIndex].className = 'item'
}
rows[index].className = 'item hover'
oldIndex = index
},
move: function (step) { // moves the selection either up or down (unless it's not possible) step is either +1 or -1.
if (elem.style.visibility === 'hidden') return '' // nothing to move if there is no dropDown. (this happens if the user hits escape and then down or up)
if (ix + step === -1 || ix + step === rows.length) return rows[ix].__hint // NO CIRCULAR SCROLLING.
ix += step
p.highlight(ix)
return rows[ix].__hint// txtShadow.value = uRows[uIndex].__hint ;
},
onmouseselection: function () { } // it will be overwritten.
}
return p
}
function setEndOfContenteditable (contentEditableElement) {
let range, selection
if (document.createRange) {
// Firefox, Chrome, Opera, Safari, IE 9+
range = document.createRange()// Create a range (a range is a like the selection but invisible)
range.selectNodeContents(contentEditableElement)// Select the entire contents of the element with the range
range.collapse(false)// collapse the range to the end point. false means collapse to end rather than the start
selection = window.getSelection()// get the selection object (allows you to change selection)
selection.removeAllRanges()// remove any selections already made
selection.addRange(range)// make the range you have just created the visible selection
} else if (document.selection) {
// IE 8 and lower
range = document.body.createTextRange()// Create a range (a range is a like the selection but invisible)
range.moveToElementText(contentEditableElement)// Select the entire contents of the element with the range
range.collapse(false)// collapse the range to the end point. false means collapse to end rather than the start
range.select()// Select the range (make it the visible selection
}
}
function calculateWidthForText (text) {
if (spacer === undefined) { // on first call only.
spacer = document.createElement('span')
spacer.style.visibility = 'hidden'
spacer.style.position = 'fixed'
spacer.style.outline = '0'
spacer.style.margin = '0'
spacer.style.padding = '0'
spacer.style.border = '0'
spacer.style.left = '0'
spacer.style.whiteSpace = 'pre'
spacer.style.fontSize = fontSize
spacer.style.fontFamily = fontFamily
spacer.style.fontWeight = 'normal'
document.body.appendChild(spacer)
}
spacer.textContent = text
return spacer.getBoundingClientRect().right
}
const rs = {
onArrowDown: function () { }, // defaults to no action.
onArrowUp: function () { }, // defaults to no action.
onEnter: function () { }, // defaults to no action.
onTab: function () { }, // defaults to no action.
startFrom: 0,
options: [],
element: null,
elementHint: null,
elementStyle: null,
wrapper, // Only to allow easy access to the HTML elements to the final user (possibly for minor customizations)
show: function (element, startPos, options) {
this.startFrom = startPos
this.wrapper.remove()
if (this.elementHint) {
this.elementHint.remove()
this.elementHint = null
}
if (fontSize === '') {
fontSize = window.getComputedStyle(element).getPropertyValue('font-size')
}
if (fontFamily === '') {
fontFamily = window.getComputedStyle(element).getPropertyValue('font-family')
}
dropDown.style.marginLeft = '0'
dropDown.style.marginTop = element.getBoundingClientRect().height + 'px'
this.options = options.map(String)
if (this.element !== element) {
this.element = element
this.elementStyle = {
zIndex: this.element.style.zIndex,
position: this.element.style.position,
backgroundColor: this.element.style.backgroundColor,
borderColor: this.element.style.borderColor
}
}
this.element.style.zIndex = 3
this.element.style.position = 'relative'
this.element.style.backgroundColor = 'transparent'
this.element.style.borderColor = 'transparent'
this.elementHint = element.cloneNode()
this.elementHint.className = 'autocomplete hint'
this.elementHint.style.zIndex = 2
this.elementHint.style.position = 'absolute'
this.elementHint.onfocus = () => { this.element.focus() }
if (this.element.addEventListener) {
this.element.removeEventListener('keydown', keyDownHandler)
this.element.addEventListener('keydown', keyDownHandler, false)
this.element.removeEventListener('blur', onBlurHandler)
this.element.addEventListener('blur', onBlurHandler, false)
}
wrapper.appendChild(this.elementHint)
wrapper.appendChild(dropDown)
element.parentElement.appendChild(wrapper)
this.repaint(element)
},
setText: function (text) {
this.element.innerText = text
},
getText: function () {
return this.element.innerText
},
hideDropDown: function () {
this.wrapper.remove()
if (this.elementHint) {
this.elementHint.remove()
this.elementHint = null
dropDownController.hide()
this.element.style.zIndex = this.elementStyle.zIndex
this.element.style.position = this.elementStyle.position
this.element.style.backgroundColor = this.elementStyle.backgroundColor
this.element.style.borderColor = this.elementStyle.borderColor
}
},
repaint: function (element) {
let text = element.innerText
text = text.replace('\n', '')
const optionsLength = this.options.length
// breaking text in leftSide and token.
const token = text.substring(this.startFrom)
leftSide = text.substring(0, this.startFrom)
for (let i = 0; i < optionsLength; i++) {
const opt = this.options[i]
if ((!config.caseSensitive && opt.toLowerCase().indexOf(token.toLowerCase()) === 0) ||
(config.caseSensitive && opt.indexOf(token) === 0)) { // <-- how about upperCase vs. lowercase
this.elementHint.innerText = leftSide + token + opt.substring(token.length)
this.elementHint.realInnerText = leftSide + opt
break
}
}
// moving the dropDown and refreshing it.
dropDown.style.left = calculateWidthForText(leftSide) + 'px'
dropDownController.refresh(token, this.options)
this.elementHint.style.width = calculateWidthForText(this.elementHint.innerText) + 10 + 'px'
const wasDropDownHidden = (dropDown.style.visibility === 'hidden')
if (!wasDropDownHidden) { this.elementHint.style.width = calculateWidthForText(this.elementHint.innerText) + dropDown.clientWidth + 'px' }
}
}
const dropDownController = createDropDownController(dropDown, rs)
const keyDownHandler = function (e) {
// console.log("Keydown:" + e.keyCode);
e = e || window.event
const keyCode = e.keyCode
if (this.elementHint == null) return
if (keyCode === 33) { return } // page up (do nothing)
if (keyCode === 34) { return } // page down (do nothing);
if (keyCode === 27) { // escape
rs.hideDropDown()
rs.element.focus()
e.preventDefault()
e.stopPropagation()
return
}
let text = this.element.innerText
text = text.replace('\n', '')
if (config.confirmKeys.indexOf(keyCode) >= 0) { // (autocomplete triggered)
if (keyCode === 9) {
if (this.elementHint.innerText.length === 0) {
rs.onTab()
}
}
if (this.elementHint.innerText.length > 0) { // if there is a hint
if (this.element.innerText !== this.elementHint.realInnerText) {
this.element.innerText = this.elementHint.realInnerText
rs.hideDropDown()
setEndOfContenteditable(this.element)
if (keyCode === 9) {
rs.element.focus()
e.preventDefault()
e.stopPropagation()
}
}
}
return
}
if (keyCode === 13) { // enter (autocomplete triggered)
if (this.elementHint.innerText.length === 0) { // if there is a hint
rs.onEnter()
} else {
const wasDropDownHidden = (dropDown.style.visibility === 'hidden')
dropDownController.hide()
if (wasDropDownHidden) {
rs.hideDropDown()
rs.element.focus()
rs.onEnter()
return
}
this.element.innerText = this.elementHint.realInnerText
rs.hideDropDown()
setEndOfContenteditable(this.element)
e.preventDefault()
e.stopPropagation()
}
return
}
if (keyCode === 40) { // down
const token = text.substring(this.startFrom)
const m = dropDownController.move(+1)
if (m === '') { rs.onArrowDown() }
this.elementHint.innerText = leftSide + token + m.substring(token.length)
this.elementHint.realInnerText = leftSide + m
e.preventDefault()
e.stopPropagation()
return
}
if (keyCode === 38) { // up
const token = text.substring(this.startFrom)
const m = dropDownController.move(-1)
if (m === '') { rs.onArrowUp() }
this.elementHint.innerText = leftSide + token + m.substring(token.length)
this.elementHint.realInnerText = leftSide + m
e.preventDefault()
e.stopPropagation()
}
}.bind(rs)
const onBlurHandler = e => {
rs.hideDropDown()
// console.log("Lost focus.");
}
dropDownController.onmouseselection = (text, rs) => {
rs.element.innerText = rs.elementHint.innerText = leftSide + text
rs.hideDropDown()
window.setTimeout(() => {
rs.element.focus()
setEndOfContenteditable(rs.element)
}, 1)
}
return rs
}