diff --git a/lib/models/selection.js b/lib/models/selection.js index 15615c8ae..810f23362 100644 --- a/lib/models/selection.js +++ b/lib/models/selection.js @@ -253,7 +253,7 @@ class Selection extends SelectionRecord { /** * Move the selection forward `n` characters. * - * @param {Number} n + * @param {Number} n (optional) * @return {Selection} selection */ @@ -271,7 +271,7 @@ class Selection extends SelectionRecord { /** * Move the selection backward `n` characters. * - * @param {Number} n + * @param {Number} n (optional) * @return {Selection} selection */ @@ -286,6 +286,118 @@ class Selection extends SelectionRecord { }) } + /** + * Extend the focus point forward `n` characters. + * + * @param {Number} n (optional) + * @return {Selection} selection + */ + + extendForward(n = 1) { + if (!this.isCollapsed) { + throw new Error('The selection must be collapsed before extending.') + } + + return this.merge({ + focusOffset: this.focusOffset + n, + isBackward: false + }) + } + + /** + * Extend the focus point backward `n` characters. + * + * @param {Number} n (optional) + * @return {Selection} selection + */ + + extendBackward(n = 1) { + if (!this.isCollapsed) { + throw new Error('The selection must be collapsed before extending.') + } + + return this.merge({ + focusOffset: this.focusOffset - n, + isBackward: true + }) + } + + /** + * Extend the focus forward to the start of a `node`. + * + * @param {Node} node + * @return {Selection} selection + */ + + extendForwardToStartOf(node) { + if (!this.isCollapsed) { + throw new Error('The selection must be collapsed before extending.') + } + + return this.merge({ + focusKey: node.key, + focusOffset: 0, + isBackward: false + }) + } + + /** + * Extend the focus backward to the start of a `node`. + * + * @param {Node} node + * @return {Selection} selection + */ + + extendBackwardToStartOf(node) { + if (!this.isCollapsed) { + throw new Error('The selection must be collapsed before extending.') + } + + return this.merge({ + focusKey: node.key, + focusOffset: 0, + isBackward: true + }) + } + + /** + * Extend the focus forward to the end of a `node`. + * + * @param {Node} node + * @return {Selection} selection + */ + + extendForwardToEndOf(node) { + if (!this.isCollapsed) { + throw new Error('The selection must be collapsed before extending.') + } + + return this.merge({ + focusKey: node.key, + focusOffset: node.length, + isBackward: false + }) + } + + /** + * Extend the focus backward to the end of a `node`. + * + * @param {Node} node + * @return {Selection} selection + */ + + extendBackwardToEndOf(node) { + if (!this.isCollapsed) { + throw new Error('The selection must be collapsed before extending.') + } + + return this.merge({ + focusKey: node.key, + focusOffset: node.length, + isBackward: true + }) + } + } /** diff --git a/lib/models/state.js b/lib/models/state.js index 308ccdc73..2a47c289a 100644 --- a/lib/models/state.js +++ b/lib/models/state.js @@ -46,7 +46,9 @@ const SELECTION_LIKE_METHODS = [ 'moveToEndOf', 'moveToRangeOf', 'moveForward', - 'moveBackward' + 'moveBackward', + 'extendForward', + 'extendBackward' ] /** @@ -196,51 +198,6 @@ class State extends StateRecord { return this.selection.isAtEndOf(node) } - /** - * Backspace. - * - * @return {State} state - */ - - backspace() { - let state = this - - // When not collapsed, remove the entire selection. - if (!state.isCollapsed) { - state = state.removeRange() - state = state.moveToStart() - return state - } - - // When already at the start of the content, there's nothing to do. - if (state.isAtStartOf(state)) return state - - // When at start of a node, merge backwards into the previous node. - const { startNode } = state - if (state.isAtStartOf(startNode)) { - const { selection, startOffset } = state - const parent = state.getParentNode(startNode) - const previous = state.getPreviousNode(parent).nodes.first() - const range = selection.merge({ - anchorKey: previous.key, - anchorOffset: previous.length, - focusKey: startNode.key, - focusOffset: 0, - isBackward: false - }) - - state = state.removeRange(range) - return state - } - - // Otherwise, remove one character behind of the cursor. - const { endOffset } = state - const startOffset = endOffset - 1 - state = state.removeCharacters(startNode.key, startOffset, endOffset) - state = state.moveBackward() - return state - } - /** * Delete a single character. * @@ -250,73 +207,260 @@ class State extends StateRecord { delete() { let state = this - // When not collapsed, remove the entire selection range. - if (!state.isCollapsed) { - state = state.removeRange() - state = state.moveToStart() - return state - } + // When collapsed, there's nothing to do. + if (state.isCollapsed) return state - // When already at the end of the content, there's nothing to do. - if (state.isAtEndOf(state)) return state + // Otherwise, delete and update the selection. + state = state.deleteAtRange(state.selection) + state = state.moveToStart() + return state + } - // When at end of a node, merge forwards into the next node. - const { startNode } = state - if (state.isAtEndOf(startNode)) { - const { selection, startOffset } = state - const parent = state.getParentNode(startNode) - const next = state.getNextNode(parent).nodes.first() - const range = selection.merge({ - anchorKey: startNode.key, - anchorOffset: startNode.length, - focusKey: next.key, - focusOffset: 0, - isBackward: false + /** + * Delete everything in a `range`. + * + * @param {Selection} range + * @return {State} state + */ + + deleteAtRange(range) { + let state = this + + // If the range is collapsed, there's nothing to do. + if (range.isCollapsed) return state + + const { startKey, startOffset, endKey, endOffset } = range + let startNode = state.getNode(startKey) + + // If the start and end nodes are the same, remove the matching characters. + if (startKey == endKey) { + let { characters } = startNode + + characters = characters.filterNot((char, i) => { + return startOffset <= i && i < endOffset }) - state = state.removeRange(range) + startNode = startNode.merge({ characters }) + state = state.updateNode(startNode) return state } - // Otherwise, remove one character ahead of the cursor. - const { startOffset } = state - const endOffset = startOffset + 1 - state = state.removeCharacters(startNode.key, startOffset, endOffset) + // Otherwise, remove the text from the first and last nodes... + const startRange = Selection.create({ + anchorKey: startKey, + anchorOffset: startOffset, + focusKey: startKey, + focusOffset: startNode.length + }) + const endRange = Selection.create({ + anchorKey: endKey, + anchorOffset: 0, + focusKey: endKey, + focusOffset: endOffset + }) + + state = state.deleteAtRange(startRange) + state = state.deleteAtRange(endRange) + + // Then remove any nodes in between the top-most start and end nodes... + let startParent = state.getParentNode(startKey) + let endParent = state.getParentNode(endKey) + + const startGrandestParent = state.nodes.find((node) => { + return node == startParent || node.hasNode(startParent) + }) + + const endGrandestParent = state.nodes.find((node) => { + return node == endParent || node.hasNode(endParent) + }) + + const nodes = state.nodes + .takeUntil(node => node == startGrandestParent) + .set(startGrandestParent.key, startGrandestParent) + .concat(state.nodes.skipUntil(node => node == endGrandestParent)) + + state = state.merge({ nodes }) + + // Then bring the end text node into the start node. + let endText = state.getNode(endKey) + startParent = startParent.pushNode(endText) + endParent = endParent.removeNode(endText) + state = state.updateNode(startParent) + state = state.updateNode(endParent) + return state + } + + /** + * Delete backward `n` characters at the current selection. + * + * @param {Number} n (optional) + * @return {State} state + */ + + deleteBackward(n = 1) { + let state = this + let selection = state.selection + + // Determine what the selection should be after deleting. + const startNode = state.startNode + + if (state.isCollapsed && state.isAtStartOf(startNode)) { + const parent = state.getParentNode(startNode) + const previous = state.getPreviousNode(parent).nodes.first() + selection = selection.moveToEndOf(previous) + } + + else if (state.isCollapsed && !state.isAtEndOf(state)) { + selection = selection.moveBackward(n) + } + + // Delete backward and then update the selection. + state = state.deleteBackwardAtRange(state.selection) + state = state.merge({ selection }) + return state + } + + /** + * Delete backward `n` characters at a `range`. + * + * @param {Selection} range + * @param {Number} n (optional) + * @return {State} state + */ + + deleteBackwardAtRange(range, n = 1) { + let state = this + + // When collapsed at the end of the document, there's nothing to do. + if (range.isCollapsed && range.isAtEndOf(state)) return state + + // When the range is still expanded, just do a regular delete. + if (range.isExpanded) return state.deleteAtRange(range) + + // When at start of a text node, merge forwards into the next text node. + const { startKey } = range + const startNode = state.getNode(startKey) + + if (range.isAtStartOf(startNode)) { + const parent = state.getParentNode(startNode) + const previous = state.getPreviousNode(parent).nodes.first() + range = range.extendBackwardToEndOf(previous) + state = state.deleteAtRange(range) + return state + } + + // Otherwise, remove `n` characters behind of the cursor. + range = range.extendBackward(n) + state = state.deleteAtRange(range) + return state + } + + /** + * Delete forward `n` characters at the current selection. + * + * @param {Number} n (optional) + * @return {State} state + */ + + deleteForward(n = 1) { + let state = this + state = state.deleteForwardAtRange(state.selection) + return state + } + + /** + * Delete forward `n` characters at a `range`. + * + * @param {Selection} range + * @param {Number} n (optional) + * @return {State} state + */ + + deleteForwardAtRange(range, n = 1) { + let state = this + + // When collapsed at the end of the document, there's nothing to do. + if (range.isCollapsed && range.isAtEndOf(state)) return state + + // When the range is still expanded, just do a regular delete. + if (range.isExpanded) return state.deleteAtRange(range) + + // When at end of a text node, merge forwards into the next text node. + const { startKey } = range + const startNode = state.getNode(startKey) + + if (range.isAtEndOf(startNode)) { + const parent = state.getParentNode(startNode) + const next = state.getNextNode(parent).nodes.first() + range = range.extendForwardToStartOf(next) + state = state.deleteAtRange(range) + return state + } + + // Otherwise, remove `n` characters ahead of the cursor. + range = range.extendForward(n) + state = state.deleteAtRange(range) return state } /** * Insert a `text` string at the current cursor position. * - * @param {String} text + * @param {String or Node or OrderedMap} data * @return {State} state */ - insert(text) { + insert(data) { + let state = this + state = state.insertAtRange(state.selection, data) + + // When the data is a string of characters... + if (typeof data == 'string') { + state = state.moveForward(data.length) + } + + return state + } + + /** + * Insert `data` at a `range`. + * + * @param {Selection} range + * @param {String or Node or OrderedMap} data + * @return {State} state + */ + + insertAtRange(range, data) { let state = this // When still expanded, remove the current range first. - if (state.isExpanded) { - state = state.delete() + if (range.isExpanded) { + state = state.deleteAtRange(range) + range = range.moveToStart() } - // Insert text at the current cursor. - const ranges = [{ text }] - let { startNode, startOffset } = state - let { characters } = startNode - let newCharacters = convertRangesToCharacters(ranges) - const { size } = newCharacters + // When the data is a string of characters... + if (typeof data == 'string') { - // Splice in the new characters. - characters = characters.slice(0, startOffset) - .concat(newCharacters) - .concat(characters.slice(startOffset + size - 1, Infinity)) + // Insert text at the current cursor. + const ranges = [{ text: data }] + let { startNode, startOffset } = state + let { characters } = startNode + let newCharacters = convertRangesToCharacters(ranges) + const { size } = newCharacters + + // Splice in the new characters. + characters = characters.slice(0, startOffset) + .concat(newCharacters) + .concat(characters.slice(startOffset + size - 1, Infinity)) + + // Update the existing text node. + startNode = startNode.merge({ characters }) + state = state.updateNode(startNode) + return state + } - // Update the existing text node and the selection. - startNode = startNode.merge({ characters }) - state = state.updateNode(startNode) - state = state.moveForward(size) return state } @@ -360,40 +504,45 @@ class State extends StateRecord { split() { let state = this - state = state.splitRange() + state = state.splitAtRange(state.selection) - const parent = state.getParentNode(state.startKey) + const parent = state.getParentNode(state.startNode) const next = state.getNextNode(parent) const text = next.nodes.first() state = state.moveToStartOf(text) + // const next = state.getNextTextNode(state.startNode) + // state = state.moveToStartOf(next) + return state } /** - * Split the nodes at a `selection`. + * Split the nodes at a `range`. * - * @param {Selection} selection (optional) + * @param {Selection} range * @return {State} state */ - splitRange(selection = this.selection) { + splitAtRange(range) { let state = this - // If there's an existing selection, remove it first. - if (!selection.isCollapsed) { - state = state.removeRange(selection) - selection = selection.moveToStart() + // If the range is expanded, remove it first. + if (range.isExpanded) { + state = state.deleteAtRange(range) + range = range.moveToStart() } + const { startKey, startOffset } = range + const startNode = state.getNode(startKey) + // Split the text node's characters. - const { startNode, startOffset } = state - const parent = state.getParentNode(startNode) - const { characters , length } = startNode + const { characters, length } = startNode const firstCharacters = characters.take(startOffset) const secondCharacters = characters.takeLast(length - startOffset) // Create a new first node with only the first set of characters. + const parent = state.getParentNode(startNode) const firstText = startNode.set('characters', firstCharacters) const firstNode = parent.updateNode(firstText) @@ -416,6 +565,7 @@ class State extends StateRecord { .set(secondNode.key, secondNode) .concat(afters) + // If the state is the grandparent, just merge, otherwise deep merge. if (grandparent == state) { state = state.merge({ nodes }) } else { @@ -426,52 +576,6 @@ class State extends StateRecord { return state } - /** - * Merge the nodes between `selection`. - * - * @param {Selection} selection (optional) - * @return {State} state - */ - - removeRange(selection = this.selection) { - let state = this - - // If the selection is collapsed, there's nothing to do. - if (selection.isCollapsed) return state - - // If the start and end nodes are the same, just remove the matching text. - const { startKey, startOffset, endKey, endOffset } = selection - if (startKey == endKey) { - return state.removeCharacters(startKey, startOffset, endOffset) - } - - // Otherwise, remove the text from the first and last nodes... - let startText = state.getNode(startKey) - state = state.removeCharacters(startKey, startOffset, startText.length) - state = state.removeCharacters(endKey, 0, endOffset) - - // Then remove any nodes in between the top-most start and end nodes... - let startNode = state.getParentNode(startKey) - let endNode = state.getParentNode(endKey) - const startParent = state.nodes.find(node => node == startNode || node.hasNode(startNode)) - const endParent = state.nodes.find(node => node == endNode || node.hasNode(endNode)) - - const nodes = state.nodes - .takeUntil(node => node == startParent) - .set(startParent.key, startParent) - .concat(state.nodes.skipUntil(node => node == endParent)) - - state = state.merge({ nodes }) - - // Then bring the end text node into the start node. - let endText = state.getNode(endKey) - startNode = startNode.pushNode(endText) - endNode = endNode.removeNode(endText) - state = state.updateNode(startNode) - state = state.updateNode(endNode) - return state - } - } /** diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 4c5c8e7d1..c503e6507 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -32,7 +32,7 @@ const CORE_PLUGIN = { e.preventDefault() return isWord(e) ? state.backspaceWord() - : state.backspace() + : state.deleteBackward() } case 'delete': { @@ -41,7 +41,7 @@ const CORE_PLUGIN = { e.preventDefault() return isWord(e) ? state.deleteWord() - : state.delete() + : state.deleteForward() } case 'y': {