jsoneditor
Version:
A web-based tool to view, edit, format, and validate JSON
448 lines (401 loc) • 14.4 kB
JavaScript
'use strict'
import { createAbsoluteAnchor } from './createAbsoluteAnchor'
import { addClassName, getSelection, removeClassName, setSelection } from './util'
import { translate } from './i18n'
/**
* A context menu
* @param {Object[]} items Array containing the menu structure
* TODO: describe structure
* @param {Object} [options] Object with options. Available options:
* {function} close Callback called when the
* context menu is being closed.
* {boolean} limitHeight Whether ContextMenu height should be
* limited or not.
* @constructor
*/
export class ContextMenu {
constructor (items, options) {
this.dom = {}
const me = this
const dom = this.dom
this.anchor = undefined
this.items = items
this.eventListeners = {}
this.selection = undefined // holds the selection before the menu was opened
this.onClose = options ? options.close : undefined
this.limitHeight = options ? options.limitHeight : false
// create root element
const root = document.createElement('div')
root.className = 'jsoneditor-contextmenu-root'
dom.root = root
// create a container element
const menu = document.createElement('div')
menu.className = 'jsoneditor-contextmenu'
dom.menu = menu
root.appendChild(menu)
// create a list to hold the menu items
const list = document.createElement('ul')
list.className = 'jsoneditor-menu'
menu.appendChild(list)
dom.list = list
dom.items = [] // list with all buttons
// create a (non-visible) button to set the focus to the menu
const focusButton = document.createElement('button')
focusButton.type = 'button'
dom.focusButton = focusButton
const li = document.createElement('li')
li.style.overflow = 'hidden'
li.style.height = '0'
li.appendChild(focusButton)
list.appendChild(li)
function createMenuItems (list, domItems, items) {
items.forEach(item => {
if (item.type === 'separator') {
// create a separator
const separator = document.createElement('div')
separator.className = 'jsoneditor-separator'
const li = document.createElement('li')
li.appendChild(separator)
list.appendChild(li)
} else {
const domItem = {}
// create a menu item
const li = document.createElement('li')
list.appendChild(li)
// create a button in the menu item
const button = document.createElement('button')
button.type = 'button'
button.className = item.className
domItem.button = button
if (item.title) {
button.title = item.title
}
if (item.click) {
button.onclick = event => {
event.preventDefault()
me.hide()
item.click()
}
}
li.appendChild(button)
// create the contents of the button
if (item.submenu) {
// add the icon to the button
const divIcon = document.createElement('div')
divIcon.className = 'jsoneditor-icon'
button.appendChild(divIcon)
const divText = document.createElement('div')
divText.className = 'jsoneditor-text' +
(item.click ? '' : ' jsoneditor-right-margin')
divText.appendChild(document.createTextNode(item.text))
button.appendChild(divText)
let buttonSubmenu
if (item.click) {
// submenu and a button with a click handler
button.className += ' jsoneditor-default'
const buttonExpand = document.createElement('button')
buttonExpand.type = 'button'
domItem.buttonExpand = buttonExpand
buttonExpand.className = 'jsoneditor-expand'
const buttonExpandInner = document.createElement('div')
buttonExpandInner.className = 'jsoneditor-expand'
buttonExpand.appendChild(buttonExpandInner)
li.appendChild(buttonExpand)
if (item.submenuTitle) {
buttonExpand.title = item.submenuTitle
}
buttonSubmenu = buttonExpand
} else {
// submenu and a button without a click handler
const divExpand = document.createElement('div')
divExpand.className = 'jsoneditor-expand'
button.appendChild(divExpand)
buttonSubmenu = button
}
// attach a handler to expand/collapse the submenu
buttonSubmenu.onclick = event => {
event.preventDefault()
me._onExpandItem(domItem)
buttonSubmenu.focus()
}
// create the submenu
const domSubItems = []
domItem.subItems = domSubItems
const ul = document.createElement('ul')
domItem.ul = ul
ul.className = 'jsoneditor-menu'
ul.style.height = '0'
li.appendChild(ul)
createMenuItems(ul, domSubItems, item.submenu)
} else {
// no submenu, just a button with clickhandler
const icon = document.createElement('div')
icon.className = 'jsoneditor-icon'
button.appendChild(icon)
const text = document.createElement('div')
text.className = 'jsoneditor-text'
text.appendChild(document.createTextNode(translate(item.text)))
button.appendChild(text)
}
domItems.push(domItem)
}
})
}
createMenuItems(list, this.dom.items, items)
// TODO: when the editor is small, show the submenu on the right instead of inline?
// calculate the max height of the menu with one submenu expanded
this.maxHeight = 0 // height in pixels
items.forEach(item => {
const height = (items.length + (item.submenu ? item.submenu.length : 0)) * 24
me.maxHeight = Math.max(me.maxHeight, height)
})
}
/**
* Get the currently visible buttons
* @return {Array.<HTMLElement>} buttons
* @private
*/
_getVisibleButtons () {
const buttons = []
const me = this
this.dom.items.forEach(item => {
buttons.push(item.button)
if (item.buttonExpand) {
buttons.push(item.buttonExpand)
}
if (item.subItems && item === me.expandedItem) {
item.subItems.forEach(subItem => {
buttons.push(subItem.button)
if (subItem.buttonExpand) {
buttons.push(subItem.buttonExpand)
}
// TODO: change to fully recursive method
})
}
})
return buttons
}
/**
* Attach the menu to an anchor
* @param {HTMLElement} anchor Anchor where the menu will be attached as sibling.
* @param {HTMLElement} frame The root of the JSONEditor window
* @param {Boolean=} ignoreParent ignore anchor parent in regard to the calculation of the position, needed when the parent position is absolute
*/
show (anchor, frame, ignoreParent) {
this.hide()
// determine whether to display the menu below or above the anchor
let showBelow = true
const parent = anchor.parentNode
const anchorRect = anchor.getBoundingClientRect()
const parentRect = parent.getBoundingClientRect()
const frameRect = frame.getBoundingClientRect()
const me = this
this.dom.absoluteAnchor = createAbsoluteAnchor(anchor, frame, () => {
me.hide()
})
if (anchorRect.bottom + this.maxHeight < frameRect.bottom) {
// fits below -> show below
} else if (anchorRect.top - this.maxHeight > frameRect.top) {
// fits above -> show above
showBelow = false
} else {
// doesn't fit above nor below -> show below
}
const topGap = ignoreParent ? 0 : (anchorRect.top - parentRect.top)
// position the menu
if (showBelow) {
// display the menu below the anchor
const anchorHeight = anchor.offsetHeight
this.dom.menu.style.left = '0'
this.dom.menu.style.top = topGap + anchorHeight + 'px'
this.dom.menu.style.bottom = ''
} else {
// display the menu above the anchor
this.dom.menu.style.left = '0'
this.dom.menu.style.top = ''
this.dom.menu.style.bottom = '0px'
}
if (this.limitHeight) {
const margin = 10 // make sure there is a little margin left
const maxPossibleMenuHeight = showBelow
? frameRect.bottom - anchorRect.bottom - margin
: anchorRect.top - frameRect.top - margin
this.dom.list.style.maxHeight = maxPossibleMenuHeight + 'px'
this.dom.list.style.overflowY = 'auto'
}
// attach the menu to the temporary, absolute anchor
// parent.insertBefore(this.dom.root, anchor);
this.dom.absoluteAnchor.appendChild(this.dom.root)
// move focus to the first button in the context menu
this.selection = getSelection()
this.anchor = anchor
setTimeout(() => {
me.dom.focusButton.focus()
}, 0)
if (ContextMenu.visibleMenu) {
ContextMenu.visibleMenu.hide()
}
ContextMenu.visibleMenu = this
}
/**
* Hide the context menu if visible
*/
hide () {
// remove temporary absolutely positioned anchor
if (this.dom.absoluteAnchor) {
this.dom.absoluteAnchor.destroy()
delete this.dom.absoluteAnchor
}
// remove the menu from the DOM
if (this.dom.root.parentNode) {
this.dom.root.parentNode.removeChild(this.dom.root)
if (this.onClose) {
this.onClose()
}
}
if (ContextMenu.visibleMenu === this) {
ContextMenu.visibleMenu = undefined
}
}
/**
* Expand a submenu
* Any currently expanded submenu will be hided.
* @param {Object} domItem
* @private
*/
_onExpandItem (domItem) {
const me = this
const alreadyVisible = (domItem === this.expandedItem)
// hide the currently visible submenu
const expandedItem = this.expandedItem
if (expandedItem) {
// var ul = expandedItem.ul;
expandedItem.ul.style.height = '0'
expandedItem.ul.style.padding = ''
setTimeout(() => {
if (me.expandedItem !== expandedItem) {
expandedItem.ul.style.display = ''
removeClassName(expandedItem.ul.parentNode, 'jsoneditor-selected')
}
}, 300) // timeout duration must match the css transition duration
this.expandedItem = undefined
}
if (!alreadyVisible) {
const ul = domItem.ul
ul.style.display = 'block'
// eslint-disable-next-line no-unused-expressions
ul.clientHeight // force a reflow in Firefox
setTimeout(() => {
if (me.expandedItem === domItem) {
let childsHeight = 0
for (let i = 0; i < ul.childNodes.length; i++) {
childsHeight += ul.childNodes[i].clientHeight
}
ul.style.height = childsHeight + 'px'
ul.style.padding = '5px 10px'
}
}, 0)
addClassName(ul.parentNode, 'jsoneditor-selected')
this.expandedItem = domItem
}
}
/**
* Handle onkeydown event
* @param {Event} event
* @private
*/
_onKeyDown (event) {
const target = event.target
const keynum = event.which
let handled = false
let buttons, targetIndex, prevButton, nextButton
if (keynum === 27) { // ESC
// hide the menu on ESC key
// restore previous selection and focus
if (this.selection) {
setSelection(this.selection)
}
if (this.anchor) {
this.anchor.focus()
}
this.hide()
handled = true
} else if (keynum === 9) { // Tab
if (!event.shiftKey) { // Tab
buttons = this._getVisibleButtons()
targetIndex = buttons.indexOf(target)
if (targetIndex === buttons.length - 1) {
// move to first button
buttons[0].focus()
handled = true
}
} else { // Shift+Tab
buttons = this._getVisibleButtons()
targetIndex = buttons.indexOf(target)
if (targetIndex === 0) {
// move to last button
buttons[buttons.length - 1].focus()
handled = true
}
}
} else if (keynum === 37) { // Arrow Left
if (target.className === 'jsoneditor-expand') {
buttons = this._getVisibleButtons()
targetIndex = buttons.indexOf(target)
prevButton = buttons[targetIndex - 1]
if (prevButton) {
prevButton.focus()
}
}
handled = true
} else if (keynum === 38) { // Arrow Up
buttons = this._getVisibleButtons()
targetIndex = buttons.indexOf(target)
prevButton = buttons[targetIndex - 1]
if (prevButton && prevButton.className === 'jsoneditor-expand') {
// skip expand button
prevButton = buttons[targetIndex - 2]
}
if (!prevButton) {
// move to last button
prevButton = buttons[buttons.length - 1]
}
if (prevButton) {
prevButton.focus()
}
handled = true
} else if (keynum === 39) { // Arrow Right
buttons = this._getVisibleButtons()
targetIndex = buttons.indexOf(target)
nextButton = buttons[targetIndex + 1]
if (nextButton && nextButton.className === 'jsoneditor-expand') {
nextButton.focus()
}
handled = true
} else if (keynum === 40) { // Arrow Down
buttons = this._getVisibleButtons()
targetIndex = buttons.indexOf(target)
nextButton = buttons[targetIndex + 1]
if (nextButton && nextButton.className === 'jsoneditor-expand') {
// skip expand button
nextButton = buttons[targetIndex + 2]
}
if (!nextButton) {
// move to first button
nextButton = buttons[0]
}
if (nextButton) {
nextButton.focus()
handled = true
}
handled = true
}
// TODO: arrow left and right
if (handled) {
event.stopPropagation()
event.preventDefault()
}
}
}
// currently displayed context menu, a singleton. We may only have one visible context menu
ContextMenu.visibleMenu = undefined