diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index 3493a2b..b8adabc 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -1,9 +1,10 @@ // @flow weak -import { createElement as h, Component } from 'react' +import { createElement as h, PureComponent } from 'react' import initial from 'lodash/initial' import ActionMenu from './menu/ActionMenu' +import FloatingMenu from './menu/FloatingMenu' import { escapeHTML, unescapeHTML } from '../utils/stringUtils' import { getInnerText, insideRect, findParentWithAttribute } from '../utils/domUtils' import { stringConvert, valueType, isUrl } from '../utils/typeUtils' @@ -11,7 +12,7 @@ import { compileJSONPointer } from '../eson' import type { ESONObjectProperty, ESON, SearchResultStatus, Path } from '../types' -export default class JSONNode extends Component { +export default class JSONNode extends PureComponent { static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url' state = { @@ -42,8 +43,16 @@ export default class JSONNode extends Component { className: 'jsoneditor-node jsoneditor-object' }, [ this.renderExpandButton(), - this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), - this.renderActionMenuButton(), + // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), + // this.renderActionMenuButton(), + this.renderFloatingMenu([ + {type: 'sort'}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ]), this.renderProperty(prop, index, data, options), this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), this.renderError(data.error) @@ -88,8 +97,16 @@ export default class JSONNode extends Component { className: 'jsoneditor-node jsoneditor-array' }, [ this.renderExpandButton(), - this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), - this.renderActionMenuButton(), + // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), + // this.renderActionMenuButton(), + this.renderFloatingMenu([ + {type: 'sort'}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ]), this.renderProperty(prop, index, data, options), this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), this.renderError(data.error) @@ -130,8 +147,16 @@ export default class JSONNode extends Component { className: 'jsoneditor-node' }, [ this.renderPlaceholder(), - this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), - this.renderActionMenuButton(), + // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), + // this.renderActionMenuButton(), + this.renderFloatingMenu([ + // {text: 'String', onClick: this.props.events.onChangeType, type: 'checkbox', checked: false}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ]), this.renderProperty(prop, index, data, options), this.renderSeparator(), this.renderValue(data.value, data.searchResult, options), @@ -151,8 +176,8 @@ export default class JSONNode extends Component { onKeyDown: this.handleKeyDownAppend }, [ this.renderPlaceholder(), - this.renderActionMenu('append', this.state.appendMenu, this.handleCloseAppendActionMenu), - this.renderAppendActionMenuButton(), + // this.renderActionMenu('append', this.state.appendMenu, this.handleCloseAppendActionMenu), + // this.renderAppendActionMenuButton(), this.renderReadonly(text) ]) } @@ -402,6 +427,15 @@ export default class JSONNode extends Component { ]) } + renderFloatingMenu (items) { + return h(FloatingMenu, { + key: 'menu', + path: this.props.path, + events: this.props.events, + items + }) + } + renderAppendActionMenuButton () { const className = 'jsoneditor-button jsoneditor-actionmenu' + ((this.state.appendOpen) ? ' jsoneditor-visible' : '') @@ -450,24 +484,6 @@ export default class JSONNode extends Component { this.setState({ appendMenu: null }) } - shouldComponentUpdate (nextProps, nextState) { - let prop - - for (prop in nextProps) { - if (nextProps.hasOwnProperty(prop) && this.props[prop] !== nextProps[prop]) { - return true - } - } - - for (prop in nextState) { - if (nextState.hasOwnProperty(prop) && this.state[prop] !== nextState[prop]) { - return true - } - } - - return false - } - static getRootName (data, options) { return typeof options.name === 'string' ? options.name diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index b5ec31a..147a630 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -69,9 +69,9 @@ export default class TreeMode extends Component { 'right': this.moveRight, 'home': this.moveHome, 'end': this.moveEnd, - 'cut': this.handleCut, - 'copy': this.handleCopy, - 'paste': this.handlePaste, + 'cut': this.handleKeyDownCut, + 'copy': this.handleKeyDownCopy, + 'paste': this.handleKeyDownPaste, 'undo': this.handleUndo, 'redo': this.handleRedo, 'find': this.handleFocusFind, @@ -95,6 +95,10 @@ export default class TreeMode extends Component { onRemove: this.handleRemove, onSort: this.handleSort, + onCut: this.handleMenuCut, + onCopy: this.handleMenuCopy, + onPaste: this.handleMenuPaste, + onExpand: this.handleExpand, // TODO: now we're passing not just events but also other methods. reorganize this or rename 'state.events' @@ -393,12 +397,57 @@ export default class TreeMode extends Component { moveEnd(event.target) } - handleCut = (event) => { - const { data, selection } = this.state - + handleKeyDownCut = (event) => { + const { selection } = this.state if (selection) { event.preventDefault() + } + this.handleCut(selection) + } + handleKeyDownCopy = (event) => { + const { selection } = this.state + if (selection) { + event.preventDefault() + } + this.handleCopy(selection) + } + + handleKeyDownPaste = (event) => { + const { clipboard, selection } = this.state + if (clipboard && clipboard.length > 0) { + event.preventDefault() + if (selection) { + this.handlePaste(clipboard, selection, null) + } + else { + // no selection -> paste after current path + const path = this.findDataPathFromElement(event.target) + this.handlePaste(clipboard, null, path) + } + } + } + + handleMenuCut = (path) => { + const selection = { start: { path }, end: { path }} + this.handleCut(selection) + } + + handleMenuCopy = (path) => { + const selection = { start: { path }, end: { path }} + this.handleCopy(selection) + } + + handleMenuPaste = (path) => { + const { clipboard } = this.state + if (clipboard && clipboard.length > 0) { + this.handlePaste(clipboard, null, path) + } + } + + handleCut = (selection: ESONSelection) => { + if (selection && selection.start && selection.end) { + const data = this.state.data const paths = pathsFromSelection(data, selection) const clipboard = contentsFromPaths(data, paths) @@ -415,12 +464,9 @@ export default class TreeMode extends Component { } } - handleCopy = (event) => { - const { data, selection } = this.state - - if (selection) { - event.preventDefault() - + handleCopy = (selection: ESONSelection) => { + if (selection && selection.start && selection.end) { + const data = this.state.data const paths = pathsFromSelection(data, selection) const clipboard = contentsFromPaths(data, paths) @@ -432,15 +478,12 @@ export default class TreeMode extends Component { } } - handlePaste = (event) => { - const { data, clipboard } = this.state + handlePaste = (clipboard, selection: ESONSelection, path: JSONPath) => { + const { data } = this.state if (clipboard && clipboard.length > 0) { - event.preventDefault() - // FIXME: handle pasting in an empty object or array - const path = this.findDataPathFromElement(event.target) if (path && path.length > 0) { const parentPath = initial(path) const parent = getIn(data, toEsonPath(data, parentPath)) @@ -468,6 +511,9 @@ export default class TreeMode extends Component { this.handlePatch(patch) } } + else if (selection){ + console.log('TODO: replace selection') + } } } @@ -649,11 +695,12 @@ export default class TreeMode extends Component { const path = this.findDataPathFromElement(event.target.firstChild) if (path) { // TODO: implement a better solution to keep focus in the editor than selecting the action menu. Most also be solved for undo/redo for example - const element = findNode(this.refs.contents, path) - const actionMenuButton = element && element.querySelector('button.jsoneditor-actionmenu') - if (actionMenuButton) { - actionMenuButton.focus() - } + // --> focus to menu? + // const element = findNode(this.refs.contents, path) + // const actionMenuButton = element && element.querySelector('button.jsoneditor-actionmenu') + // if (actionMenuButton) { + // actionMenuButton.focus() + // } } } diff --git a/src/components/menu/FloatingMenu.js b/src/components/menu/FloatingMenu.js new file mode 100644 index 0000000..f6c731a --- /dev/null +++ b/src/components/menu/FloatingMenu.js @@ -0,0 +1,105 @@ +// @flow weak + +import { createElement as h, PureComponent } from 'react' +import { keyComboFromEvent } from '../../utils/keyBindings' + +const MENU_CONTAINER_CLASS_NAME = 'jsoneditor-floating-menu-container' +const MENU_CLASS_NAME = 'jsoneditor-floating-menu' +const MENU_ITEM_CLASS_NAME = 'jsoneditor-floating-menu-item' + +// Array: Sort | Map | Filter | Duplicate | Cut | Copy | Remove +// advanced sort (asc, desc, nested fields, custom comparator) +// sort, map, filter, open a popup covering the editor (not the whole page) +// (or if it's small, can be a dropdown) +// Object: Sort | Duplicate | Cut | Copy | Remove +// simple sort (asc/desc) +// Value: [x] String | Duplicate | Cut | Copy | Remove +// String is a checkmark +// Between: Insert Structure | Insert Value | Insert Object | Insert Array | Paste +// inserting (value selected): [field] [value] +// inserting (array selected): (immediately show the "Between" menu to create the first item) +// inserting (object selected): (immediately show the "Between" menu to create the first property) +// +// Selection: Duplicate | Cut | Copy | Paste | Remove +// +// menu must have vertical orientation on small screens? +// +// icons +// cut +// copy +// paste +// duplicate +// remove +// sort +// transform ??? -> filter? cog? +// undo +// redo +// expand ??? +// collapse ??? +// format/compact ??? +// +// https://github.com/FortAwesome/Font-Awesome/wiki/Customize-Font-Awesome +// http://fontastic.me/ +// --> have to create my own icons I guess :( + +// TODO: show quick keys in the title of the menu items +const CREATE_TYPE = { + sort: (path, events) => h('button', { + key: 'sort', + className: MENU_ITEM_CLASS_NAME, + onClick: () => events.onSort(path), + title: 'Sort' + }, 'Sort'), + + duplicate: (path, events) => h('button', { + key: 'duplicate', + className: MENU_ITEM_CLASS_NAME, + onClick: () => events.onDuplicate(path), + title: 'Duplicate' + }, 'Duplicate'), + + cut: (path, events) => h('button', { + key: 'cut', + className: MENU_ITEM_CLASS_NAME, + onClick: () => events.onCut(path), + title: 'Cut' + }, 'Cut'), + + copy: (path, events) => h('button', { + key: 'copy', + className: MENU_ITEM_CLASS_NAME, + onClick: () => events.onCopy(path), + title: 'Copy' + }, 'Copy'), + + paste: (path, events) => h('button', { + key: 'paste', + className: MENU_ITEM_CLASS_NAME, + onClick: () => events.onPaste(path), + title: 'Paste' + }, 'Paste'), + + remove: (path, events) => h('button', { + key: 'remove', + className: MENU_ITEM_CLASS_NAME, + onClick: () => events.onRemove(path), + title: 'Remove' + }, 'Remove'), +} + +export default class FloatingMenu extends PureComponent { + render () { + return h('div', {className: MENU_CONTAINER_CLASS_NAME}, + h('div', {className: MENU_CLASS_NAME}, this.props.items.map(item => { + const type = typeof item === 'string' ? item : item.type + const createType = CREATE_TYPE[type] + if (createType) { + return createType(this.props.path, this.props.events) + } + else { + throw new Error('Unknown type of menu item for floating menu: ' + JSON.stringify(item)) + } + }) + )) + } +} diff --git a/src/jsoneditor.less b/src/jsoneditor.less index d6213f6..582916c 100644 --- a/src/jsoneditor.less +++ b/src/jsoneditor.less @@ -5,6 +5,9 @@ @black: #1A1A1A; @contentsMinHeight: 150px; @theme-color: #3883fa; +@floating-menu-background: #4d4d4d; +@floating-menu-color: #fff; +@selectedColor: #e5e5e5; .jsoneditor { border: 1px solid @theme-color; @@ -257,7 +260,7 @@ div.jsoneditor-value.jsoneditor-empty::after { } .jsoneditor-selected { - background-color: #f0f0f0; + background-color: @selectedColor; } .jsoneditor-highlight { @@ -556,6 +559,74 @@ button.jsoneditor-type-Array.jsoneditor-selected span.jsoneditor-icon { background-position: -96px 0; } +/******************************* Floatting Menu **********************************/ + +div.jsoneditor-node { + + div.jsoneditor-floating-menu-container { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + z-index: 999; + + div.jsoneditor-floating-menu { + margin: 10px; + white-space: nowrap; + border-radius: 5px; + box-shadow: 0 2px 6px 0 rgba(0,0,0,.24); + + &:after { + content:''; + position: absolute; + top: 100%; + left: 10%; + margin-left: -10px; + margin-top: -10px; + width: 0; + height: 0; + border-top: solid 10px @floating-menu-background; + border-left: solid 10px transparent; + border-right: solid 10px transparent; + } + + button.jsoneditor-floating-menu-item { + color: @floating-menu-color; + background: @floating-menu-background; + border: none; + border-right: 1px solid lighten(@floating-menu-background, 10%); + padding: 10px; + cursor: pointer; + + &:hover { + background: lighten(@floating-menu-background, 10%); + } + + &:first-child { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + &:last-child { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + border-right: none; + } + } + } + } + + &:hover { + background-color: @selectedColor; + + div.jsoneditor-floating-menu-container { + display: inherit; + } + } +} + + +/******************************* **********************************/ div.jsoneditor-modes { position: relative; display: inline-block;