UNPKG

jsoneditor

Version:

A web-based tool to view, edit, format, and validate JSON

504 lines (438 loc) 18.3 kB
'use strict' // Helper functions for handling both string and object option formats const getOptionText = (option) => { if (option == null) return '' return typeof option === 'string' ? option : (option.text || '') } const getOptionValue = (option) => { if (option == null) return '' return typeof option === 'string' ? option : (option.value || option.text || '') } const isObject = (value) => { return value !== null && typeof value === 'object' } const normalizeCase = (text = '', config) => { return config.caseSensitive ? text : text.toLowerCase() } const ensureStringOption = (option) => { // Keep objects as-is, but convert primitives to strings to prevent breaking changes return isObject(option) ? option : String(option) } const getHighlightedTextParts = (token, row, config) => { const rowText = getOptionText(row) const rowValue = getOptionValue(row) const tokenLower = normalizeCase(token, config) const rowTextLower = normalizeCase(rowText, config) const rowValueLower = normalizeCase(rowValue, config) // Find the best match position for highlighting let matchIndex = -1 const matchLength = token.length let displayText = rowText // Prefer text matches over value matches for display if (rowTextLower.indexOf(tokenLower) > -1) { matchIndex = rowTextLower.indexOf(tokenLower) displayText = rowText } else if (rowValueLower.indexOf(tokenLower) > -1) { matchIndex = rowValueLower.indexOf(tokenLower) displayText = rowValue } if (matchIndex > -1) { return { beforeText: displayText.substring(0, matchIndex), matchText: displayText.substring(matchIndex, matchIndex + matchLength), afterText: displayText.substring(matchIndex + matchLength), displayText } } else { // No match found, return the display text as-is return { beforeText: '', matchText: '', afterText: displayText, displayText } } } // Helper function to reduce duplication in filter functions const matchesFilter = (token, match, config, matchFunction) => { const normalizedToken = normalizeCase(token, config) if (isObject(match)) { // Check both text and value properties for object matches const matchText = getOptionText(match) const matchValue = getOptionValue(match) const normalizedText = normalizeCase(matchText, config) const normalizedValue = normalizeCase(matchValue, config) return matchFunction(normalizedText, normalizedToken) || matchFunction(normalizedValue, normalizedToken) } else { // Handle simple string matches const normalizedMatch = normalizeCase(String(match), config) return matchFunction(normalizedMatch, normalizedToken) } } const defaultFilterFunction = { start: function (token, match, config) { return matchesFilter(token, match, config, (normalizedText, normalizedToken) => normalizedText.indexOf(normalizedToken) === 0 ) }, contain: function (token, match, config) { return matchesFilter(token, match, config, (normalizedText, normalizedToken) => normalizedText.indexOf(normalizedToken) > -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(token, match, 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 = '' const { beforeText, matchText, afterText } = getHighlightedTextParts(token, row, config) // Add text before match (if any) if (beforeText) { divRow.appendChild(document.createTextNode(beforeText)) } // Add highlighted match (if any) if (matchText) { const b = document.createElement('b') b.appendChild(document.createTextNode(matchText)) divRow.appendChild(b) } // Add text after match if (afterText) { divRow.appendChild(document.createTextNode(afterText)) } elem.appendChild(divRow) return divRow }) if (rows.length === 0) { return // nothing to show. } const firstRowText = getOptionText(rows[0].__hint) if (rows.length === 1 && normalizeCase(token, config) === normalizeCase(firstRowText, config)) { return // do not show the dropDown if it has only one element which matches what we have just displayed. } 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(ensureStringOption) 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) // Use the same filter logic as the dropdown for consistency const filterFn = typeof config.filter === 'function' ? config.filter : defaultFilterFunction[config.filter] for (let i = 0; i < optionsLength; i++) { const opt = this.options[i] if (filterFn && filterFn(token, opt, config)) { const optText = getOptionText(opt) const optValue = getOptionValue(opt) // For hints, prioritize matches that start with the token for better UX let hintText = '' const normalizedToken = normalizeCase(token, config) const normalizedOptText = normalizeCase(optText, config) const normalizedOptValue = normalizeCase(optValue, config) if (normalizedOptText.indexOf(normalizedToken) === 0) { // Text starts with token - show completion hintText = leftSide + token + optText.substring(token.length) } else if (normalizedOptValue.indexOf(normalizedToken) === 0) { // Value starts with token - show completion hintText = leftSide + token + optValue.substring(token.length) } else { // Contains match but doesn't start with token - just show the token hintText = leftSide + token } this.elementHint.innerText = hintText this.elementHint.realInnerText = leftSide + optValue 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 + getOptionText(m).substring(token.length) this.elementHint.realInnerText = leftSide + getOptionValue(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 + getOptionText(m).substring(token.length) this.elementHint.realInnerText = leftSide + getOptionValue(m) e.preventDefault() e.stopPropagation() } }.bind(rs) const onBlurHandler = e => { rs.hideDropDown() // console.log("Lost focus."); } dropDownController.onmouseselection = (option, rs) => { const optionValue = getOptionValue(option) rs.element.innerText = rs.elementHint.innerText = leftSide + optionValue rs.hideDropDown() window.setTimeout(() => { rs.element.focus() setEndOfContenteditable(rs.element) }, 1) } return rs }