Multi-select mouse events now honor the event's window (#1098)

* Multi-select mouse events now honor the event's window

This prevents errors from opening JSONEditor in a child window (such as `window.open` or third-party libraries like react-new-window).

* Add a demo of showing a JSONEditor in a child window

* Minor spelling fixes

* Further improvements to new window support

Use `event.view` instead of the global `window`. Copy VanillaPicker styles in the example so that the color picker is correctly styled.

* Add 'noopener'

This helps security; otherwise, JavaScript running in the context of the new window can access the original window object via the `window.opener` property, even if it's a different origin.

* Update modal handling for new windows

There are two approaches we could take; we could use the existing modalAnchor property and make callers responsible for setting it, or we could modify the default handling to default to the node's container body. For now, I chose to make callers responsible.

Rename showTransformModal's `anchor` parameter to `container`; as far as I can tell, `anchor` didn't work properly.
This commit is contained in:
Josh Kelley 2020-09-23 04:06:12 -04:00 committed by GitHub
parent f3694c3126
commit 69fbe63f0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 123 additions and 23 deletions

View File

@ -36,7 +36,8 @@
'number': 123, 'number': 123,
'object': {'a': 'b', 'c': 'd'}, 'object': {'a': 'b', 'c': 'd'},
'time': 1575599819000, 'time': 1575599819000,
'string': 'Hello World' 'string': 'Hello World',
'onlineDemo': 'https://jsoneditoronline.org/'
} }
editor.set(json) editor.set(json)
} }

View File

@ -0,0 +1,87 @@
<!DOCTYPE HTML>
<html>
<head>
<title>JSONEditor | New window</title>
<link href="../dist/jsoneditor.css" rel="stylesheet" type="text/css">
<script src="../dist/jsoneditor.js"></script>
<style type="text/css">
#jsoneditor {
width: 500px;
height: 500px;
}
</style>
</head>
<body>
<p>
<button id="openNewEditor">Open Editor in New Window</button>
<button id="setJSON">Set JSON</button>
<button id="getJSON">Get JSON</button>
</p>
<script>
let editor
function openNewEditor() {
const child = window.open("", "_blank", "width=400,height=400")
child.document.title = 'JSONEditor | New window'
child.onunload = function () {
editor = undefined
}
// make the necessary styles available within the child window
// for JSONEditor
const baseUrl = window.location.href.slice(0, window.location.href.lastIndexOf('/'))
const jsonEditorStyles = child.document.createElement("link")
jsonEditorStyles.setAttribute("href", baseUrl + "/../dist/jsoneditor.css")
jsonEditorStyles.setAttribute("rel", "stylesheet")
child.document.head.append(jsonEditorStyles)
// for vanilla-picker
const colorPickerStyles = JSONEditor.VanillaPicker.StyleElement.cloneNode(true)
child.document.head.append(colorPickerStyles)
const container = child.document.createElement("div")
child.document.body.append(container)
// create the editor
const options = {
// Show sort and transform modals in the child window, not the parent.
modalAnchor: child.document.body
}
editor = new JSONEditor(container, options)
}
// create a new window
document.getElementById('openNewEditor').onclick = openNewEditor
// set json
document.getElementById('setJSON').onclick = function () {
if (!editor) {
openNewEditor()
}
const json = {
'array': [1, 2, 3],
'boolean': true,
'color': '#82b92c',
'null': null,
'number': 123,
'object': {'a': 'b', 'c': 'd'},
'time': 1575599819000,
'string': 'Hello World'
}
editor.set(json)
}
// get json
document.getElementById('getJSON').onclick = function () {
if (!editor) {
alert('No editor is open')
} else {
const json = editor.get()
alert(JSON.stringify(json, null, 2))
}
}
</script>
</body>
</html>

View File

@ -2574,7 +2574,7 @@ export class Node {
// if read-only, we use the regular click behavior of an anchor // if read-only, we use the regular click behavior of an anchor
if (isUrl(this.value)) { if (isUrl(this.value)) {
event.preventDefault() event.preventDefault()
window.open(this.value, '_blank') window.open(this.value, '_blank', 'noopener')
} }
} }
break break
@ -2729,7 +2729,7 @@ export class Node {
if (target === this.dom.value) { if (target === this.dom.value) {
if (!this.editable.value || event.ctrlKey) { if (!this.editable.value || event.ctrlKey) {
if (isUrl(this.value)) { if (isUrl(this.value)) {
window.open(this.value, '_blank') window.open(this.value, '_blank', 'noopener')
handled = true handled = true
} }
} }
@ -3917,7 +3917,7 @@ export class Node {
const json = this.getValue() const json = this.getValue()
showTransformModal({ showTransformModal({
anchor: modalAnchor || DEFAULT_MODAL_ANCHOR, container: modalAnchor || DEFAULT_MODAL_ANCHOR,
json, json,
queryDescription, // can be undefined queryDescription, // can be undefined
createQuery, createQuery,
@ -4116,13 +4116,13 @@ Node.onDragStart = (nodes, event) => {
const offsetY = getAbsoluteTop(draggedNode.dom.tr) - getAbsoluteTop(firstNode.dom.tr) const offsetY = getAbsoluteTop(draggedNode.dom.tr) - getAbsoluteTop(firstNode.dom.tr)
if (!editor.mousemove) { if (!editor.mousemove) {
editor.mousemove = addEventListener(window, 'mousemove', event => { editor.mousemove = addEventListener(event.view, 'mousemove', event => {
Node.onDrag(nodes, event) Node.onDrag(nodes, event)
}) })
} }
if (!editor.mouseup) { if (!editor.mouseup) {
editor.mouseup = addEventListener(window, 'mouseup', event => { editor.mouseup = addEventListener(event.view, 'mouseup', event => {
Node.onDragEnd(nodes, event) Node.onDragEnd(nodes, event)
}) })
} }
@ -4378,11 +4378,11 @@ Node.onDragEnd = (nodes, event) => {
delete editor.drag delete editor.drag
if (editor.mousemove) { if (editor.mousemove) {
removeEventListener(window, 'mousemove', editor.mousemove) removeEventListener(event.view, 'mousemove', editor.mousemove)
delete editor.mousemove delete editor.mousemove
} }
if (editor.mouseup) { if (editor.mouseup) {
removeEventListener(window, 'mouseup', editor.mouseup) removeEventListener(event.view, 'mouseup', editor.mouseup)
delete editor.mouseup delete editor.mouseup
} }

View File

@ -398,7 +398,7 @@ previewmode._showTransformModal = function () {
this._renderPreview() // update array count this._renderPreview() // update array count
showTransformModal({ showTransformModal({
anchor: modalAnchor || DEFAULT_MODAL_ANCHOR, container: modalAnchor || DEFAULT_MODAL_ANCHOR,
json, json,
queryDescription, // can be undefined queryDescription, // can be undefined
createQuery, createQuery,

View File

@ -244,7 +244,7 @@ textmode.create = function (container, options = {}) {
// TODO: this anchor falls below the margin of the content, // TODO: this anchor falls below the margin of the content,
// therefore the normal a.href does not work. We use a click event // therefore the normal a.href does not work. We use a click event
// for now, but this should be fixed. // for now, but this should be fixed.
window.open(poweredBy.href, poweredBy.target) window.open(poweredBy.href, poweredBy.target, 'noopener')
} }
this.menu.appendChild(poweredBy) this.menu.appendChild(poweredBy)
} }
@ -484,7 +484,7 @@ textmode._showTransformModal = function () {
const json = this.get() const json = this.get()
showTransformModal({ showTransformModal({
anchor: modalAnchor || DEFAULT_MODAL_ANCHOR, container: modalAnchor || DEFAULT_MODAL_ANCHOR,
json, json,
queryDescription, // can be undefined queryDescription, // can be undefined
createQuery, createQuery,

View File

@ -25,7 +25,8 @@ import {
repair, repair,
selectContentEditable, selectContentEditable,
setSelectionOffset, setSelectionOffset,
isValidationErrorChanged isValidationErrorChanged,
getWindow
} from './util' } from './util'
import { autocomplete } from './autocomplete' import { autocomplete } from './autocomplete'
import { setLanguage, setLanguages, translate } from './i18n' import { setLanguage, setLanguages, translate } from './i18n'
@ -136,7 +137,7 @@ treemode._setOptions = function (options) {
// when there is not enough space below, and there is enough space above // when there is not enough space below, and there is enough space above
const pickerHeight = 300 // estimated height of the color picker const pickerHeight = 300 // estimated height of the color picker
const top = parent.getBoundingClientRect().top const top = parent.getBoundingClientRect().top
const windowHeight = window.innerHeight const windowHeight = getWindow(parent).innerHeight
const showOnTop = ((windowHeight - top) < pickerHeight && top > pickerHeight) const showOnTop = ((windowHeight - top) < pickerHeight && top > pickerHeight)
new VanillaPicker({ new VanillaPicker({
@ -1280,7 +1281,7 @@ treemode._updateDragDistance = function (event) {
/** /**
* Start multi selection of nodes by dragging the mouse * Start multi selection of nodes by dragging the mouse
* @param event * @param {MouseEvent} event
* @private * @private
*/ */
treemode._onMultiSelectStart = function (event) { treemode._onMultiSelectStart = function (event) {
@ -1302,12 +1303,12 @@ treemode._onMultiSelectStart = function (event) {
const editor = this const editor = this
if (!this.mousemove) { if (!this.mousemove) {
this.mousemove = addEventListener(window, 'mousemove', event => { this.mousemove = addEventListener(event.view, 'mousemove', event => {
editor._onMultiSelect(event) editor._onMultiSelect(event)
}) })
} }
if (!this.mouseup) { if (!this.mouseup) {
this.mouseup = addEventListener(window, 'mouseup', event => { this.mouseup = addEventListener(event.view, 'mouseup', event => {
editor._onMultiSelectEnd(event) editor._onMultiSelectEnd(event)
}) })
} }
@ -1317,7 +1318,7 @@ treemode._onMultiSelectStart = function (event) {
/** /**
* Multiselect nodes by dragging * Multiselect nodes by dragging
* @param event * @param {MouseEvent} event
* @private * @private
*/ */
treemode._onMultiSelect = function (event) { treemode._onMultiSelect = function (event) {
@ -1360,9 +1361,10 @@ treemode._onMultiSelect = function (event) {
/** /**
* End of multiselect nodes by dragging * End of multiselect nodes by dragging
* @param {MouseEvent} event
* @private * @private
*/ */
treemode._onMultiSelectEnd = function () { treemode._onMultiSelectEnd = function (event) {
// set focus to the context menu button of the first node // set focus to the context menu button of the first node
if (this.multiselection.nodes[0]) { if (this.multiselection.nodes[0]) {
this.multiselection.nodes[0].dom.menu.focus() this.multiselection.nodes[0].dom.menu.focus()
@ -1373,11 +1375,11 @@ treemode._onMultiSelectEnd = function () {
// cleanup global event listeners // cleanup global event listeners
if (this.mousemove) { if (this.mousemove) {
removeEventListener(window, 'mousemove', this.mousemove) removeEventListener(event.view, 'mousemove', this.mousemove)
delete this.mousemove delete this.mousemove
} }
if (this.mouseup) { if (this.mouseup) {
removeEventListener(window, 'mouseup', this.mouseup) removeEventListener(event.view, 'mouseup', this.mouseup)
delete this.mouseup delete this.mouseup
} }
} }

View File

@ -455,6 +455,16 @@ export function isArray (obj) {
return Object.prototype.toString.call(obj) === '[object Array]' return Object.prototype.toString.call(obj) === '[object Array]'
} }
/**
* Gets a DOM element's Window. This is normally just the global `window`
* variable, but if we opened a child window, it may be different.
* @param {HTMLElement} element
* @return {Window}
*/
export function getWindow (element) {
return element.ownerDocument.defaultView;
}
/** /**
* Retrieve the absolute left value of a DOM element * Retrieve the absolute left value of a DOM element
* @param {Element} elem A dom element, for example a div * @param {Element} elem A dom element, for example a div
@ -789,7 +799,7 @@ export function isFirefox () {
var _ieVersion = -1 var _ieVersion = -1
/** /**
* Add and event listener. Works for all browsers * Add an event listener. Works for all browsers
* @param {Element} element An html element * @param {Element} element An html element
* @param {string} action The action, for example "click", * @param {string} action The action, for example "click",
* without the prefix "on" * without the prefix "on"
@ -1139,7 +1149,7 @@ export function getInputSelection (el) {
} }
/** /**
* Returns the index for certaion position in text element * Returns the index for certain position in text element
* @param {DOMElement} el A dom element of a textarea or input text. * @param {DOMElement} el A dom element of a textarea or input text.
* @param {Number} row row value, > 0, if exceeds rows number - last row will be returned * @param {Number} row row value, > 0, if exceeds rows number - last row will be returned
* @param {Number} column column value, > 0, if exceeds column length - end of column will be returned * @param {Number} column column value, > 0, if exceeds column length - end of column will be returned
@ -1499,7 +1509,7 @@ export function contains (array, item) {
} }
/** /**
* Checkes if validation has changed from the previous execution * Checks if validation has changed from the previous execution
* @param {Array} currErr current validation errors * @param {Array} currErr current validation errors
* @param {Array} prevErr previous validation errors * @param {Array} prevErr previous validation errors
*/ */