Implemented caret selection
This commit is contained in:
parent
76e4bf5e16
commit
ae1e39ba3f
|
@ -2,9 +2,9 @@ import last from 'lodash/last'
|
|||
import initial from 'lodash/initial'
|
||||
import isEmpty from 'lodash/isEmpty'
|
||||
import first from 'lodash/first'
|
||||
import { findRootPath } from './eson'
|
||||
import { findRootPath, toJSON } from './eson'
|
||||
import { getIn } from './utils/immutabilityHelpers'
|
||||
import { findUniqueName } from './utils/stringUtils'
|
||||
import { duplicateInText, findUniqueName } from './utils/stringUtils'
|
||||
import { isObject, stringConvert } from './utils/typeUtils'
|
||||
import { compareAsc, compareDesc } from './utils/arrayUtils'
|
||||
import { compileJSONPointer, parseJSONPointer } from './jsonPointer'
|
||||
|
@ -80,37 +80,63 @@ export function changeType (json, path, type) {
|
|||
* @return {Array}
|
||||
*/
|
||||
export function duplicate (json, selection) {
|
||||
// console.log('duplicate', path)
|
||||
if (isEmpty(selection.multi)) {
|
||||
return []
|
||||
}
|
||||
if (!isEmpty(selection.multi)) {
|
||||
const rootPath = findRootPath(selection)
|
||||
const root = getIn(json, rootPath)
|
||||
const paths = selection.multi.map(parseJSONPointer)
|
||||
|
||||
const rootPath = findRootPath(selection)
|
||||
const root = getIn(json, rootPath)
|
||||
const paths = selection.multi.map(parseJSONPointer)
|
||||
if (Array.isArray(root)) {
|
||||
const lastPath = last(paths)
|
||||
const offset = lastPath ? (parseInt(last(lastPath), 10) + 1) : 0
|
||||
|
||||
if (Array.isArray(root)) {
|
||||
const lastPath = last(paths)
|
||||
const offset = lastPath ? (parseInt(last(lastPath), 10) + 1) : 0
|
||||
|
||||
return paths.map((path, index) => ({
|
||||
op: 'copy',
|
||||
from: compileJSONPointer(path),
|
||||
path: compileJSONPointer(rootPath.concat(index + offset))
|
||||
}))
|
||||
}
|
||||
else { // 'object'
|
||||
return paths.map(path => {
|
||||
const prop = last(path)
|
||||
const newProp = findUniqueName(prop, root)
|
||||
|
||||
return {
|
||||
return paths.map((path, index) => ({
|
||||
op: 'copy',
|
||||
from: compileJSONPointer(path),
|
||||
path: compileJSONPointer(rootPath.concat(newProp))
|
||||
}
|
||||
})
|
||||
path: compileJSONPointer(rootPath.concat(index + offset))
|
||||
}))
|
||||
}
|
||||
else { // 'object'
|
||||
return paths.map(path => {
|
||||
const prop = last(path)
|
||||
const newProp = findUniqueName(prop, root)
|
||||
|
||||
return {
|
||||
op: 'copy',
|
||||
from: compileJSONPointer(path),
|
||||
path: compileJSONPointer(rootPath.concat(newProp))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.type === 'caret') {
|
||||
if (selection.anchorOffset === selection.focusOffset) {
|
||||
// no text selected -> duplicate the current node
|
||||
return duplicate(json, {
|
||||
type: 'multi',
|
||||
multi: [selection.path]
|
||||
})
|
||||
}
|
||||
else {
|
||||
// no text selected -> duplicate selected text
|
||||
if (selection.input === 'property') {
|
||||
const path = parseJSONPointer(selection.path)
|
||||
const parentPath = initial(path)
|
||||
const oldProperty = last(path)
|
||||
const newProperty = duplicateInText(oldProperty, selection.anchorOffset, selection.focusOffset)
|
||||
|
||||
return changeProperty(json, parentPath, oldProperty, newProperty)
|
||||
}
|
||||
else { // selection.input === 'value'
|
||||
const oldValue = String(toJSON(getIn(json, parseJSONPointer(selection.path))))
|
||||
const newValue = duplicateInText(oldValue, selection.anchorOffset, selection.focusOffset)
|
||||
|
||||
return changeValue(json, parseJSONPointer(selection.path), newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -467,7 +493,7 @@ export function convertType (value, type) {
|
|||
/**
|
||||
* Extract the patched nodes and create a selection
|
||||
* @param {JSONPatchDocument} operations
|
||||
* @return {Selection}
|
||||
* @return {Selection | null}
|
||||
*/
|
||||
export function getSelectionFromPatch (operations) {
|
||||
const paths = operations
|
||||
|
@ -483,5 +509,5 @@ export function getSelectionFromPatch (operations) {
|
|||
|
||||
// TODO: after a remove, select after?
|
||||
|
||||
return { type: 'none' }
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -330,6 +330,7 @@ export default class JSONNode extends PureComponent {
|
|||
h('div', {
|
||||
key: 'property',
|
||||
className: 'jsoneditor-property' + emptyClassName + searchClassName,
|
||||
'data-input': 'property',
|
||||
contentEditable: 'true',
|
||||
suppressContentEditableWarning: true,
|
||||
spellCheck: 'false',
|
||||
|
@ -386,6 +387,7 @@ export default class JSONNode extends PureComponent {
|
|||
key: 'value',
|
||||
className: JSONNode.getValueClass(type, itsAnUrl, isEmpty) +
|
||||
JSONNode.getSearchResultClass(searchResult),
|
||||
'data-input': 'value',
|
||||
contentEditable: 'true',
|
||||
suppressContentEditableWarning: true,
|
||||
spellCheck: 'false',
|
||||
|
|
|
@ -51,6 +51,7 @@ import { KEY_BINDINGS } from '../constants'
|
|||
import { immutableJSONPatch } from '../immutableJSONPatch'
|
||||
import {
|
||||
applyErrors,
|
||||
applySearch,
|
||||
applySelection,
|
||||
contentsFromPaths,
|
||||
expand,
|
||||
|
@ -61,13 +62,12 @@ import {
|
|||
immutableESONPatch,
|
||||
nextSearchResult,
|
||||
previousSearchResult,
|
||||
applySearch,
|
||||
SELECTION,
|
||||
syncEson
|
||||
} from '../eson'
|
||||
import TreeModeMenu from './menu/TreeModeMenu'
|
||||
import Search from './menu/Search'
|
||||
import { findParentWithAttribute, toArray } from '../utils/domUtils'
|
||||
import { findParentWithAttribute, hasAttribute, hasParent, toArray } from '../utils/domUtils'
|
||||
|
||||
const AJV_OPTIONS = {
|
||||
allErrors: true,
|
||||
|
@ -103,7 +103,7 @@ export default class TreeMode extends PureComponent {
|
|||
'cut': this.handleKeyDownCut,
|
||||
'copy': this.handleKeyDownCopy,
|
||||
'paste': this.handleKeyDownPaste,
|
||||
'duplicate': this.handleKeyDownDuplicate,
|
||||
'duplicate': this.handleDuplicate,
|
||||
'remove': this.handleKeyDownRemove,
|
||||
'undo': this.handleUndo,
|
||||
'redo': this.handleRedo,
|
||||
|
@ -164,14 +164,19 @@ export default class TreeMode extends PureComponent {
|
|||
this.applyProps(nextProps, this.props)
|
||||
}
|
||||
|
||||
// TODO: use or cleanup
|
||||
// componentDidMount () {
|
||||
// document.addEventListener('keydown', this.handleKeyDown)
|
||||
// }
|
||||
//
|
||||
// componentWillUnmount () {
|
||||
// document.removeEventListener('keydown', this.handleKeyDown)
|
||||
// }
|
||||
componentDidMount () {
|
||||
// TODO: use or cleanup
|
||||
// document.addEventListener('keydown', this.handleKeyDown)
|
||||
|
||||
document.addEventListener('selectionchange', this.handleSelectionChange)
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
// TODO: use or cleanup
|
||||
// document.removeEventListener('keydown', this.handleKeyDown)
|
||||
|
||||
document.removeEventListener('selectionchange', this.handleSelectionChange)
|
||||
}
|
||||
|
||||
// TODO: create some sort of watcher structure for these props? Is there a React pattern for that?
|
||||
applyProps (nextProps, currentProps) {
|
||||
|
@ -362,6 +367,42 @@ export default class TreeMode extends PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleSelectionChange = () => {
|
||||
// FIXME: selection change isn't supported by Safari. Create a fallback
|
||||
|
||||
const selection = this.getCaretSelection()
|
||||
|
||||
if (!isEqual(selection, this.state.selection)) {
|
||||
this.setState({selection})
|
||||
console.log('selection', JSON.stringify(selection)) // TODO: cleanup logging
|
||||
}
|
||||
}
|
||||
|
||||
getCaretSelection = () => {
|
||||
const documentSelection = window.getSelection()
|
||||
|
||||
if (documentSelection.anchorNode &&
|
||||
documentSelection.anchorNode === documentSelection.focusNode &&
|
||||
hasAttribute(documentSelection.anchorNode.parentNode, 'data-input')) {
|
||||
|
||||
const path = this.findDataPathFromElement(documentSelection.anchorNode)
|
||||
|
||||
if (hasParent(documentSelection.anchorNode, this.refs.contents) && path) {
|
||||
return {
|
||||
type: 'caret',
|
||||
path: compileJSONPointer(path),
|
||||
input: documentSelection.anchorNode.parentNode.getAttribute
|
||||
? documentSelection.anchorNode.parentNode.getAttribute('data-input')
|
||||
: null,
|
||||
anchorOffset: documentSelection.anchorOffset,
|
||||
focusOffset: documentSelection.focusOffset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
handleChangeValue = ({path, value}) => {
|
||||
this.handlePatch(changeValue(this.state.eson, path, value))
|
||||
}
|
||||
|
@ -480,19 +521,7 @@ export default class TreeMode extends PureComponent {
|
|||
if (clipboard && clipboard.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
const path = this.findDataPathFromElement(event.target)
|
||||
this.handlePatch(insertBefore(eson, path, clipboard))
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDownDuplicate = (event) => {
|
||||
const path = this.findDataPathFromElement(event.target)
|
||||
if (path) {
|
||||
const selection = { type: 'multi', multi: [path] }
|
||||
this.handlePatch(duplicate(this.state.eson, selection))
|
||||
|
||||
// apply focus to the duplicated node
|
||||
this.focusToNext(path)
|
||||
this.handlePaste()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -561,6 +590,9 @@ export default class TreeMode extends PureComponent {
|
|||
else if (selection.type === 'before-childs') {
|
||||
this.handlePatch(insertInside(eson, parseJSONPointer(selection.beforeChildsOf), clipboard), true)
|
||||
}
|
||||
else if (selection.type === 'caret') {
|
||||
this.handlePatch(insertBefore(eson, parseJSONPointer(selection.path), clipboard), true)
|
||||
}
|
||||
else {
|
||||
throw new Error(`Cannot paste at current selection ${JSON.stringify(selection)}`)
|
||||
}
|
||||
|
@ -588,11 +620,13 @@ export default class TreeMode extends PureComponent {
|
|||
}
|
||||
else if (selection.after) {
|
||||
this.handlePatch(insertAfter(eson, parseJSONPointer(selection.after), clipboard), true)
|
||||
|
||||
}
|
||||
else if (selection.beforeChildsOf) {
|
||||
this.handlePatch(insertInside(eson, parseJSONPointer(selection.beforeChildsOf), clipboard), true)
|
||||
}
|
||||
else if (selection.type === 'caret') {
|
||||
this.handlePatch(insertBefore(eson, parseJSONPointer(selection.path), clipboard), true)
|
||||
}
|
||||
else {
|
||||
throw new Error(`Cannot insert at current selection ${JSON.stringify(selection)}`)
|
||||
}
|
||||
|
@ -844,19 +878,32 @@ export default class TreeMode extends PureComponent {
|
|||
return
|
||||
}
|
||||
|
||||
this.selectionStartPointer = this.findSelectionPointerFromEvent(event.target, event.clientY)
|
||||
// don't start selecting nodes when selecting text inside the editable div of a property or value
|
||||
if (!hasAttribute(event.target, 'data-input')) {
|
||||
this.selectionStartPointer = this.findSelectionPointerFromEvent(event.target, event.clientY)
|
||||
|
||||
this.setState({ selection: null })
|
||||
this.setState({ selection: null })
|
||||
console.log('selection', JSON.stringify(null)) // TODO: cleanup logging
|
||||
}
|
||||
else {
|
||||
this.selectionStartPointer = null
|
||||
}
|
||||
}
|
||||
|
||||
handlePan = (event) => {
|
||||
if (!this.selectionStartPointer) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectionEndPointer = this.findSelectionPointerFromEvent(event.target, event.center.y)
|
||||
|
||||
const selection = this.findSelectionFromPointers(this.selectionStartPointer, this.selectionEndPointer)
|
||||
if (!isEqual(selection, this.state.selection)) {
|
||||
this.setState({ selection })
|
||||
console.log('selection', JSON.stringify(selection)) // TODO: cleanup logging
|
||||
if (isEqual(selection, this.state.selection)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({ selection })
|
||||
console.log('selection', JSON.stringify(selection)) // TODO: cleanup logging
|
||||
}
|
||||
|
||||
handlePanEnd = (event) => {
|
||||
|
@ -908,7 +955,7 @@ export default class TreeMode extends PureComponent {
|
|||
/**
|
||||
* @param {SelectionPointer} start
|
||||
* @param {SelectionPointer} end
|
||||
* @return {Selection}
|
||||
* @return {Selection | null}
|
||||
*/
|
||||
findSelectionFromPointers (start, end) {
|
||||
if (start && end) {
|
||||
|
@ -971,10 +1018,6 @@ export default class TreeMode extends PureComponent {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1106,7 +1149,7 @@ export default class TreeMode extends PureComponent {
|
|||
const selectionBefore = this.state.selection
|
||||
const selectionAfter = selectChangedContents
|
||||
? getSelectionFromPatch(operations)
|
||||
: { type: 'none' }
|
||||
: null
|
||||
|
||||
const jsonResult = immutableJSONPatch(this.state.json, operations)
|
||||
const esonResult = immutableESONPatch(this.state.eson, operations)
|
||||
|
|
|
@ -61,8 +61,9 @@ export default class TreeModeMenu extends PureComponent {
|
|||
let items = []
|
||||
|
||||
const { selection, clipboard } = this.props
|
||||
const hasCursor = selection && selection.type !== 'none'
|
||||
const hasCursor = !!selection
|
||||
const hasSelectedContent = selection ? !isEmpty(selection.multi) : false
|
||||
const hasCaret = selection && selection.type === 'caret'
|
||||
const hasClipboard = clipboard ? (clipboard.length > 0) : false
|
||||
|
||||
// mode
|
||||
|
@ -127,7 +128,7 @@ export default class TreeModeMenu extends PureComponent {
|
|||
key: 'duplicate',
|
||||
className: 'jsoneditor-duplicate',
|
||||
title: 'Duplicate current selection',
|
||||
disabled: !hasSelectedContent,
|
||||
disabled: !(hasSelectedContent || hasCaret),
|
||||
onClick: this.props.onDuplicate
|
||||
}, h('i', {className: 'fa fa-clone'})),
|
||||
h('button', {
|
||||
|
|
|
@ -29,12 +29,27 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* type: 'multi' | 'after' | 'before-childs', 'none'
|
||||
* after? string
|
||||
* multi?: string[]
|
||||
* beforeChildsOf?: string
|
||||
* }} Selection
|
||||
* @typedef {
|
||||
* {
|
||||
* type: 'multi',
|
||||
* multi: string[]
|
||||
* } |
|
||||
* {
|
||||
* type: 'after',
|
||||
* after: string
|
||||
* } |
|
||||
* {
|
||||
* type: 'before-childs',
|
||||
* beforeChildsOf: string
|
||||
* } |
|
||||
* {
|
||||
* type: 'caret',
|
||||
* path: string,
|
||||
* input: 'property' | 'value',
|
||||
* anchorOffset: number,
|
||||
* focusOffset: number
|
||||
* }
|
||||
* } Selection
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
@ -69,22 +69,6 @@ export function getInnerText (element, buffer) {
|
|||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text selection
|
||||
* http://stackoverflow.com/questions/4687808/contenteditable-selected-text-save-and-restore
|
||||
* @return {Range | TextRange | null} range
|
||||
*/
|
||||
export function getSelection() {
|
||||
if (window.getSelection) {
|
||||
const sel = window.getSelection()
|
||||
if (sel.getRangeAt && sel.rangeCount) {
|
||||
return sel.getRangeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all text of a content editable div.
|
||||
* http://stackoverflow.com/a/3806004/1262753
|
||||
|
@ -151,10 +135,30 @@ export function findParentWithClassName (element, className) {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given element has given parent as parent somewhere in the tree up
|
||||
* @param {Element} element
|
||||
* @param {Element} parent
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function hasParent(element, parent) {
|
||||
let e = element
|
||||
|
||||
while (e) {
|
||||
if (e === parent) {
|
||||
return true
|
||||
}
|
||||
|
||||
e = e.parentNode
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a HTML element contains a specific className
|
||||
* @param {Element} element
|
||||
* @param {boolean} className
|
||||
* @param {string} className
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function hasClassName (element, className) {
|
||||
|
@ -163,6 +167,18 @@ export function hasClassName (element, className) {
|
|||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether an element has a certain attribute
|
||||
* @param {Element} element
|
||||
* @param {string} attribute
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasAttribute (element, attribute) {
|
||||
return element && element.hasAttribute
|
||||
? element.hasAttribute(attribute)
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the child rect fits completely inside the parent rect.
|
||||
* @param {ClientRect} parent
|
||||
|
|
|
@ -127,3 +127,20 @@ export function toCapital(text) {
|
|||
export function compareStrings (a, b) {
|
||||
return (a < b) ? -1 : (a > b) ? 1 : 0
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Duplicate a piece of text
|
||||
* @param {string} text
|
||||
* @param {number} anchorOffset
|
||||
* @param {number} focusOffset
|
||||
* @return {string}
|
||||
*/
|
||||
export function duplicateInText(text, anchorOffset, focusOffset) {
|
||||
const startOffset = Math.min(anchorOffset, focusOffset)
|
||||
const endOffset = Math.max(anchorOffset, focusOffset)
|
||||
|
||||
return text.slice(0, endOffset) +
|
||||
text.slice(startOffset, endOffset) + // the duplicated piece of the text
|
||||
text.slice(endOffset)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { escapeHTML, unescapeHTML, findUniqueName, toCapital, compareStrings } from './stringUtils'
|
||||
import { compareStrings, duplicateInText, escapeHTML, findUniqueName, toCapital, unescapeHTML } from './stringUtils'
|
||||
|
||||
test('escapeHTML', () => {
|
||||
expect(escapeHTML(' hello ')).toEqual('\u00A0\u00A0 hello \u00A0')
|
||||
|
@ -40,3 +40,8 @@ test('compareStrings', () => {
|
|||
const array = ['b', 'c', 'a']
|
||||
expect(array.sort(compareStrings)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test('duplicateInText', () => {
|
||||
expect(duplicateInText('abcdef', 2, 4)).toEqual('abcdcdef')
|
||||
expect(duplicateInText('abcdef', 4, 2)).toEqual('abcdcdef')
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue