Implemented caret selection

This commit is contained in:
jos 2018-10-17 13:17:19 +02:00
parent 76e4bf5e16
commit ae1e39ba3f
8 changed files with 217 additions and 92 deletions

View File

@ -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
}

View File

@ -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',

View File

@ -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)

View File

@ -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', {

View File

@ -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
*/
/**

View File

@ -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

View File

@ -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)
}

View File

@ -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')
})