From 183abf8f7cb9d32bddd611976d474344b7c23ee9 Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Tue, 16 Aug 2016 12:10:01 -0700 Subject: [PATCH] refactor transforms to be modularized --- lib/index.js | 10 + lib/models/node.js | 34 +- lib/models/state.js | 831 ------------ lib/models/transform.js | 142 +- lib/models/transforms.js | 1182 ----------------- lib/transforms/at-current-range.js | 612 +++++++++ lib/transforms/at-range.js | 1117 ++++++++++++++++ lib/transforms/by-current-keys.js | 0 lib/transforms/by-key.js | 37 + lib/transforms/index.js | 188 +++ lib/transforms/on-selection.js | 211 +++ lib/transforms/options.js | 13 + lib/utils/is-in-range.js | 32 + lib/utils/normalize-block.js | 35 + lib/utils/normalize-inline.js | 35 + lib/utils/normalize-mark.js | 35 + .../normalize-node-or-mark-properties.js | 47 + 17 files changed, 2387 insertions(+), 2174 deletions(-) delete mode 100644 lib/models/transforms.js create mode 100644 lib/transforms/at-current-range.js create mode 100644 lib/transforms/at-range.js create mode 100644 lib/transforms/by-current-keys.js create mode 100644 lib/transforms/by-key.js create mode 100644 lib/transforms/index.js create mode 100644 lib/transforms/on-selection.js create mode 100644 lib/transforms/options.js create mode 100644 lib/utils/is-in-range.js create mode 100644 lib/utils/normalize-block.js create mode 100644 lib/utils/normalize-inline.js create mode 100644 lib/utils/normalize-mark.js create mode 100644 lib/utils/normalize-node-or-mark-properties.js diff --git a/lib/index.js b/lib/index.js index 80f251740..86e1217c1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -29,6 +29,12 @@ import Html from './serializers/html' import Plain from './serializers/plain' import Raw from './serializers/raw' +/** + * Transforms. + */ + +import Transforms from './transforms' + /** * Utils. */ @@ -37,6 +43,8 @@ import findDOMNode from './utils/find-dom-node' /** * Export. + * + * @type {Object} */ export { @@ -55,6 +63,7 @@ export { Selection, State, Text, + Transforms, findDOMNode } @@ -74,5 +83,6 @@ export default { Selection, State, Text, + Transforms, findDOMNode } diff --git a/lib/models/node.js b/lib/models/node.js index 3ae72a31e..418371b26 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -6,9 +6,9 @@ import Document from './document' import Inline from './inline' import Mark from './mark' import Selection from './selection' -import Transforms from './transforms' import Text from './text' import direction from 'direction' +import isInRange from '../utils/is-in-range' import includes from 'lodash/includes' import memoize from '../utils/memoize' import uid from '../utils/uid' @@ -1198,38 +1198,6 @@ function normalizeKey(key) { return key.key } -/** - * Check if an `index` of a `text` node is in a `range`. - * - * @param {Number} index - * @param {Text} text - * @param {Selection} range - * @return {Set} characters - */ - -function isInRange(index, text, range) { - const { startKey, startOffset, endKey, endOffset } = range - let matcher - - if (text.key == startKey && text.key == endKey) { - return startOffset <= index && index < endOffset - } else if (text.key == startKey) { - return startOffset <= index - } else if (text.key == endKey) { - return index < endOffset - } else { - return true - } -} - -/** - * Transforms. - */ - -for (const key in Transforms) { - Node[key] = Transforms[key] -} - /** * Export. */ diff --git a/lib/models/state.js b/lib/models/state.js index dadf1ea53..bdb855de9 100644 --- a/lib/models/state.js +++ b/lib/models/state.js @@ -399,837 +399,6 @@ class State extends new Record(DEFAULTS) { return new Transform({ state }) } - /** - * Add a `mark` to the characters in the current selection. - * - * @param {Mark} mark - * @return {State} state - */ - - addMark(mark) { - mark = normalizeMark(mark) - let state = this - let { cursorMarks, document, selection } = state - - // If the selection is collapsed, add the mark to the cursor instead. - if (selection.isCollapsed) { - const marks = document.getMarksAtRange(selection) - state = state.merge({ cursorMarks: marks.add(mark) }) - return state - } - - document = document.addMarkAtRange(selection, mark) - state = state.merge({ document }) - return state - } - - /** - * Move the selection to the start of the previous block. - * - * @return {State} state - */ - - collapseToStartOfPreviousBlock() { - let state = this - let { document, selection } = state - let blocks = document.getBlocksAtRange(selection) - let block = blocks.first() - if (!block) return state - - let previous = document.getPreviousBlock(block) - if (!previous) return state - - selection = selection.collapseToStartOf(previous) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Move the selection to the end of the previous block. - * - * @return {State} state - */ - - collapseToEndOfPreviousBlock() { - let state = this - let { document, selection } = state - let blocks = document.getBlocksAtRange(selection) - let block = blocks.first() - if (!block) return state - - let previous = document.getPreviousBlock(block) - if (!previous) return state - - selection = selection.collapseToEndOf(previous) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Move the selection to the start of the next block. - * - * @return {State} state - */ - - collapseToStartOfNextBlock() { - let state = this - let { document, selection } = state - let blocks = document.getBlocksAtRange(selection) - let block = blocks.last() - if (!block) return state - - let next = document.getNextBlock(block) - if (!next) return state - - selection = selection.collapseToStartOf(next) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Move the selection to the end of the next block. - * - * @return {State} state - */ - - collapseToEndOfNextBlock() { - let state = this - let { document, selection } = state - let blocks = document.getBlocksAtRange(selection) - let block = blocks.last() - if (!block) return state - - let next = document.getNextBlock(block) - if (!next) return state - - selection = selection.collapseToEndOf(next) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Move the selection to the start of the previous text. - * - * @return {State} state - */ - - collapseToStartOfPreviousText() { - let state = this - let { document, selection } = state - let texts = document.getTextsAtRange(selection) - let text = texts.first() - if (!text) return state - - let previous = document.getPreviousText(text) - if (!previous) return state - - selection = selection.collapseToStartOf(previous) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Move the selection to the end of the previous text. - * - * @return {State} state - */ - - collapseToEndOfPreviousText() { - let state = this - let { document, selection } = state - let texts = document.getTextsAtRange(selection) - let text = texts.first() - if (!text) return state - - let previous = document.getPreviousText(text) - if (!previous) return state - - selection = selection.collapseToEndOf(previous) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Move the selection to the start of the next text. - * - * @return {State} state - */ - - collapseToStartOfNextText() { - let state = this - let { document, selection } = state - let texts = document.getTextsAtRange(selection) - let text = texts.last() - if (!text) return state - - let next = document.getNextText(text) - if (!next) return state - - selection = selection.collapseToStartOf(next) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Move the selection to the end of the next text. - * - * @return {State} state - */ - - collapseToEndOfNextText() { - let state = this - let { document, selection } = state - let texts = document.getTextsAtRange(selection) - let text = texts.last() - if (!text) return state - - let next = document.getNextText(text) - if (!next) return state - - selection = selection.collapseToEndOf(next) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Delete at the current selection. - * - * @return {State} state - */ - - delete() { - let state = this - let { document, selection } = state - let after - - // When collapsed, there's nothing to do. - if (selection.isCollapsed) return state - - // Determine what the selection will be after deleting. - const { startText } = this - const { startKey, startOffset, endKey, endOffset } = selection - const block = document.getClosestBlock(startText) - const highest = block.getHighestChild(startText) - const previous = block.getPreviousSibling(highest) - const next = block.getNextSibling(highest) - - if ( - previous && - startOffset == 0 && - (endKey != startKey || endOffset == startText.length) - ) { - if (previous.kind == 'text') { - if (next && next.kind == 'text') { - after = selection.merge({ - anchorKey: previous.key, - anchorOffset: previous.length, - focusKey: previous.key, - focusOffset: previous.length - }) - } else { - after = selection.collapseToEndOf(previous) - } - } else { - const last = previous.getTexts().last() - after = selection.collapseToEndOf(last) - } - } - - else { - after = selection.collapseToStart() - } - - // Delete and update the selection. - document = document.deleteAtRange(selection) - selection = after - state = state.merge({ document, selection }) - return state - } - - /** - * Delete backward `n` characters at the current selection. - * - * @param {Number} n (optional) - * @return {State} state - */ - - deleteBackward(n = 1) { - let state = this - let { document, selection } = state - let after = selection - - // Determine what the selection should be after deleting. - const { startKey } = selection - const startNode = document.getDescendant(startKey) - - if (selection.isExpanded) { - after = selection.collapseToStart() - } - - else if (selection.isAtStartOf(document)) { - after = selection - } - - else if (selection.isAtStartOf(startNode)) { - const previous = document.getPreviousText(startNode) - const prevBlock = document.getClosestBlock(previous) - const prevInline = document.getClosestInline(previous) - - if (prevBlock && prevBlock.isVoid) { - after = selection - } else if (prevInline && prevInline.isVoid) { - after = selection - } else { - after = selection.collapseToEndOf(previous) - } - } - - else if (selection.isAtEndOf(startNode) && startNode.length == 1) { - const block = document.getClosestBlock(startKey) - const highest = block.getHighestChild(startKey) - const previous = block.getPreviousSibling(highest) - const next = block.getNextSibling(highest) - - if (previous) { - if (previous.kind == 'text') { - if (next && next.kind == 'text') { - after = selection.merge({ - anchorKey: previous.key, - anchorOffset: previous.length, - focusKey: previous.key, - focusOffset: previous.length - }) - } else { - after = selection.collapseToEndOf(previous) - } - } else { - const last = previous.getTexts().last() - after = selection.collapseToEndOf(last) - } - } else { - after = selection.moveBackward(n) - } - } - - else { - after = selection.moveBackward(n) - } - - // Delete backward and then update the selection. - document = document.deleteBackwardAtRange(selection, n) - selection = after - state = state.merge({ document, selection }) - return state - } - - /** - * Delete forward `n` characters at the current selection. - * - * @param {Number} n (optional) - * @return {State} state - */ - - deleteForward(n = 1) { - let state = this - let { document, selection, startText } = state - let { startKey, startOffset } = selection - let after = selection - - // Determine what the selection should be after deleting. - const block = document.getClosestBlock(startKey) - const inline = document.getClosestInline(startKey) - const highest = block.getHighestChild(startKey) - const previous = block.getPreviousSibling(highest) - const next = block.getNextSibling(highest) - - if (selection.isExpanded) { - after = selection.collapseToStart() - } - - else if ((block && block.isVoid) || (inline && inline.isVoid)) { - const nextText = document.getNextText(startKey) - const prevText = document.getPreviousText(startKey) - after = next - ? selection.collapseToStartOf(nextText) - : selection.collapseToEndOf(prevText) - } - - else if (previous && startOffset == 0 && startText.length == 1) { - if (previous.kind == 'text') { - if (next && next.kind == 'text') { - after = selection.merge({ - anchorKey: previous.key, - anchorOffset: previous.length, - focusKey: previous.key, - focusOffset: previous.length - }) - } else { - after = selection.collapseToEndOf(previous) - } - } else { - const last = previous.getTexts().last() - after = selection.collapseToEndOf(last) - } - } - - // Delete forward and then update the selection. - document = document.deleteForwardAtRange(selection, n) - selection = after - state = state.merge({ document, selection }) - return state - } - - /** - * Insert a `block` at the current selection. - * - * @param {String || Object || Block} block - * @return {State} state - */ - - insertBlock(block) { - let state = this - let { document, selection } = state - let after = selection - - // Insert the block - document = document.insertBlockAtRange(selection, block) - - // Determine what the selection should be after inserting. - const keys = state.document.getTexts().map(text => text.key) - const text = document.getTexts().find(n => !keys.includes(n.key)) - selection = selection.collapseToEndOf(text) - - // Update the document and selection. - state = state.merge({ document, selection }) - return state - } - - /** - * Insert a `fragment` at the current selection. - * - * @param {Document} fragment - * @return {State} state - */ - - insertFragment(fragment) { - let state = this - let { document, selection } = state - let after = selection - - // If there's nothing in the fragment, do nothing. - if (!fragment.length) return state - - // Lookup some nodes for determining the selection next. - const lastText = fragment.getTexts().last() - const lastInline = fragment.getClosestInline(lastText) - const beforeTexts = document.getTexts() - - // Insert the fragment. - document = document.insertFragmentAtRange(selection, fragment) - - // Determine what the selection should be after inserting. - const keys = beforeTexts.map(text => text.key) - const text = document.getTexts().findLast(n => !keys.includes(n.key)) - const previousText = text ? document.getPreviousText(text) : null - - if (text && lastInline && previousText) { - after = selection.collapseToEndOf(previousText) - } - - else if (text && lastInline) { - after = selection.collapseToStart() - } - - else if (text) { - after = selection - .collapseToStartOf(text) - .moveForward(lastText.length) - } - - else { - after = selection - .collapseToStart() - .moveForward(lastText.length) - } - - // Update the document and selection. - selection = after - state = state.merge({ document, selection }) - return state - } - - /** - * Insert a `inline` at the current selection. - * - * @param {String || Object || Block} inline - * @return {State} state - */ - - insertInline(inline) { - let state = this - let { document, selection, startText } = state - let after = selection - const hasVoid = document.hasVoidParent(startText) - - // Insert the inline - document = document.insertInlineAtRange(selection, inline) - - // Determine what the selection should be after inserting. - if (hasVoid) { - selection = selection - } - - else { - const keys = state.document.getTexts().map(text => text.key) - const text = document.getTexts().find(n => !keys.includes(n.key)) - selection = selection.collapseToEndOf(text) - } - - // Update the document and selection. - state = state.merge({ document, selection }) - return state - } - - /** - * Insert a `text` string at the current selection. - * - * @param {String} text - * @param {Set} marks (optional) - * @return {State} state - */ - - insertText(text, marks) { - let state = this - let { cursorMarks, document, selection } = state - let after - const isVoid = document.hasVoidParent(state.startText) - - // Determine what the selection should be after inserting. - if (isVoid) { - after = selection - } - - else if (selection.isExpanded) { - after = selection.collapseToStart().moveForward(text.length) - } - - else { - after = selection.moveForward(text.length) - } - - // Insert the text and update the selection. - document = document.insertTextAtRange(selection, text, marks || cursorMarks) - selection = after - state = state.merge({ document, selection }) - return state - } - - /** - * Move the selection to a specific anchor and focus point. - * - * @param {Object} properties - * @return {State} state - */ - - moveTo(properties) { - let state = this - let { document, selection } = state - - // Allow for passing a `Selection` object. - if (properties instanceof Selection) { - properties = { - anchorKey: properties.anchorKey, - anchorOffset: properties.anchorOffset, - focusKey: properties.focusKey, - focusOffset: properties.focusOffset, - isFocused: properties.isFocused - } - } - - // Pass in properties, and force `isBackward` to be re-resolved. - selection = selection.merge({ - ...properties, - isBackward: null - }) - - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - /** - * Set `properties` of the block nodes in the current selection. - * - * @param {Object} properties - * @return {State} state - */ - - setBlock(properties) { - let state = this - let { document, selection } = state - document = document.setBlockAtRange(selection, properties) - state = state.merge({ document }) - return state - } - - /** - * Set `properties` of the inline nodes in the current selection. - * - * @param {Object} properties - * @return {State} state - */ - - setInline(properties) { - let state = this - let { document, selection } = state - document = document.setInlineAtRange(selection, properties) - state = state.merge({ document }) - return state - } - - /** - * Split the block node at the current selection, to optional `depth`. - * - * @param {Number} depth (optional) - * @return {State} state - */ - - splitBlock(depth = 1) { - let state = this - let { document, selection } = state - - // Split the document. - document = document.splitBlockAtRange(selection, depth) - - // Determine what the selection should be after splitting. - const { startKey } = selection - const startNode = document.getDescendant(startKey) - const nextNode = document.getNextText(startNode) - selection = selection.collapseToStartOf(nextNode) - - state = state.merge({ document, selection }) - return state - } - - /** - * Split the inline nodes at the current selection, to optional `depth`. - * - * @param {Number} depth (optional) - * @return {State} state - */ - - splitInline(depth = Infinity) { - let state = this - let { document, selection } = state - - // Split the document. - document = document.splitInlineAtRange(selection, depth) - - // Determine what the selection should be after splitting. - const { startKey } = selection - const inlineParent = document.getClosestInline(startKey) - - if (inlineParent) { - const startNode = document.getDescendant(startKey) - const nextNode = document.getNextText(startNode) - selection = selection.collapseToStartOf(nextNode) - } - - state = state.merge({ document, selection }) - return state - } - - /** - * Remove a `mark` from the characters in the current selection. - * - * @param {Mark} mark - * @return {State} state - */ - - removeMark(mark) { - mark = normalizeMark(mark) - let state = this - let { cursorMarks, document, selection } = state - - // If the selection is collapsed, remove the mark from the cursor instead. - if (selection.isCollapsed) { - const marks = document.getMarksAtRange(selection) - state = state.merge({ cursorMarks: marks.remove(mark) }) - return state - } - - document = document.removeMarkAtRange(selection, mark) - state = state.merge({ document }) - return state - } - - /** - * Add or remove a `mark` from the characters in the current selection, - * depending on whether it's already there. - * - * @param {Mark} mark - * @return {State} state - */ - - toggleMark(mark) { - mark = normalizeMark(mark) - let state = this - let { marks, document, selection } = state - const exists = marks.some(m => m.equals(mark)) - return exists - ? state.removeMark(mark) - : state.addMark(mark) - } - - /** - * Wrap the block nodes in the current selection with a new block node with - * `properties`. - * - * @param {Object or String} properties - * @return {State} state - */ - - wrapBlock(properties) { - let state = this - let { document, selection } = state - document = document.wrapBlockAtRange(selection, properties) - state = state.merge({ document }) - return state - } - - /** - * Unwrap the current selection from a block parent with `properties`. - * - * @param {Object or String} properties - * @return {State} state - */ - - unwrapBlock(properties) { - let state = this - let { document, selection } = state - document = document.unwrapBlockAtRange(selection, properties) - state = state.merge({ document, selection }) - return state - } - - /** - * Wrap the current selection in new inline nodes with `properties`. - * - * @param {Object or String} properties - * @return {State} state - */ - - wrapInline(properties) { - let state = this - let { document, selection } = state - const { startKey } = selection - const previous = document.getPreviousText(startKey) - - document = document.wrapInlineAtRange(selection, properties) - - // Determine what the selection should be after wrapping. - if (selection.isCollapsed) { - selection = selection - } - - else if (selection.startOffset == 0) { - const text = previous - ? document.getNextText(previous) - : document.getTexts().first() - selection = selection.moveToRangeOf(text) - } - - else if (selection.startKey == selection.endKey) { - const text = document.getNextText(selection.startKey) - selection = selection.moveToRangeOf(text) - } - - else { - const anchor = document.getNextText(selection.anchorKey) - const focus = document.getDescendant(selection.focusKey) - selection = selection.merge({ - anchorKey: anchor.key, - anchorOffset: 0, - focusKey: focus.key, - focusOffset: selection.focusOffset - }) - } - - state = state.merge({ document, selection }) - return state - } - - /** - * Wrap the current selection with prefix/suffix. - * - * @param {String} prefix - * @param {String} suffix - * @return {State} state - */ - - wrapText(prefix, suffix = prefix) { - let state = this - let { document, selection } = state - let { anchorOffset, anchorKey, focusOffset, focusKey, isBackward } = selection - let after - - // Determine what the selection should be after wrapping. - if (anchorKey == focusKey) { - after = selection.moveForward(prefix.length) - } - - else { - after = selection.merge({ - anchorOffset: isBackward ? anchorOffset : anchorOffset + prefix.length, - focusOffset: isBackward ? focusOffset + prefix.length : focusOffset - }) - } - - // Wrap the text and update the state. - document = document.wrapTextAtRange(selection, prefix, suffix) - selection = after - state = state.merge({ document, selection }) - return state - } - - /** - * Unwrap the current selection from an inline parent with `properties`. - * - * @param {Object or String} properties - * @return {State} state - */ - - unwrapInline(properties) { - let state = this - let { document, selection } = state - document = document.unwrapInlineAtRange(selection, properties) - state = state.merge({ document, selection }) - return state - } - -} - -/** - * Normalize a `mark` argument, which can be a string or plain object too. - * - * @param {Mark or String or Object} mark - * @return {Mark} - */ - -function normalizeMark(mark) { - if (typeof mark == 'string') { - return Mark.create({ type: mark }) - } else { - return Mark.create(mark) - } } /** diff --git a/lib/models/transform.js b/lib/models/transform.js index 92c3385e9..b1a5fd8d5 100644 --- a/lib/models/transform.js +++ b/lib/models/transform.js @@ -1,4 +1,5 @@ +import Transforms from '../transforms' import includes from 'lodash/includes' import xor from 'lodash/xor' import { List, Record } from 'immutable' @@ -23,39 +24,13 @@ const Step = new Record({ }) /** - * Document range transforms. + * Defaults. */ -const DOCUMENT_RANGE_TRANSFORMS = [ - 'deleteAtRange', - 'deleteBackwardAtRange', - 'deleteForwardAtRange', - 'insertBlockAtRange', - 'insertFragmentAtRange', - 'insertInlineAtRange', - 'insertTextAtRange', - 'addMarkAtRange', - 'setBlockAtRange', - 'setInlineAtRange', - 'splitBlockAtRange', - 'splitInlineAtRange', - 'removeMarkAtRange', - 'toggleMarkAtRange', - 'unwrapBlockAtRange', - 'unwrapInlineAtRange', - 'wrapBlockAtRange', - 'wrapInlineAtRange', - 'wrapTextAtRange' -] - -/** - * Document node transforms. - */ - -const DOCUMENT_NODE_TRANSFORMS = [ - 'removeNodeByKey', - 'setNodeByKey', -] +const DEFAULT_PROPERTIES = { + state: null, + steps: new List() +} /** * Selection transforms. @@ -77,40 +52,7 @@ const SELECTION_TRANSFORMS = [ 'moveBackward', 'moveForward', 'moveToOffsets', - 'moveToRangeOf' -] - -/** - * State-level document transforms. - */ - -const STATE_DOCUMENT_TRANSFORMS = [ - 'delete', - 'deleteBackward', - 'deleteForward', - 'insertBlock', - 'insertFragment', - 'insertInline', - 'insertText', - 'addMark', - 'setBlock', - 'setInline', - 'splitBlock', - 'splitInline', - 'removeMark', - 'toggleMark', - 'unwrapBlock', - 'unwrapInline', - 'wrapBlock', - 'wrapInline', - 'wrapText' -] - -/** - * State selection transforms. - */ - -const STATE_SELECTION_TRANSFORMS = [ + 'moveToRangeOf', 'collapseToEndOfNextBlock', 'collapseToEndOfNextText', 'collapseToEndOfPreviousBlock', @@ -122,33 +64,6 @@ const STATE_SELECTION_TRANSFORMS = [ 'moveTo', ] -/** - * All state-level transforms. - */ - -const STATE_TRANSFORMS = [] - .concat(STATE_DOCUMENT_TRANSFORMS) - .concat(STATE_SELECTION_TRANSFORMS) - -/** - * All transforms. - */ - -const TRANSFORMS = [] - .concat(DOCUMENT_RANGE_TRANSFORMS) - .concat(DOCUMENT_NODE_TRANSFORMS) - .concat(SELECTION_TRANSFORMS) - .concat(STATE_TRANSFORMS) - -/** - * Defaults. - */ - -const DEFAULT_PROPERTIES = { - state: null, - steps: new List() -} - /** * Transform. */ @@ -224,37 +139,14 @@ class Transform extends new Record(DEFAULT_PROPERTIES) { applyStep(state, step) { const { type, args } = step + const transform = Transforms[type] - if (includes(DOCUMENT_RANGE_TRANSFORMS, type)) { - let { document, selection } = state - let [ range, ...rest ] = args - range = range.normalize(document) - document = document[type](range, ...rest) - selection = selection.normalize(document) - state = state.merge({ document, selection }) - return state + if (!transform) { + throw new Error(`Unknown transform type: "${type}".`) } - else if (includes(DOCUMENT_NODE_TRANSFORMS, type)) { - let { document, selection } = state - document = document[type](...args) - selection = selection.normalize(document) - state = state.merge({ document, selection }) - return state - } - - else if (includes(SELECTION_TRANSFORMS, type)) { - let { document, selection } = state - selection = selection[type](...args) - selection = selection.normalize(document) - state = state.merge({ selection }) - return state - } - - else if (includes(STATE_TRANSFORMS, type)) { - state = state[type](...args) - return state - } + state = transform(state, ...args) + return state } /** @@ -271,13 +163,7 @@ class Transform extends new Record(DEFAULT_PROPERTIES) { const previous = undos.peek() // If the only steps applied are selection transforms, don't snapshot. - const onlySelections = steps.every((step) => { - return ( - includes(SELECTION_TRANSFORMS, step.type) || - includes(STATE_SELECTION_TRANSFORMS, step.type) - ) - }) - + const onlySelections = steps.every(step => includes(SELECTION_TRANSFORMS, step.type)) if (onlySelections) return false // If there isn't a previous state, snapshot. @@ -392,7 +278,7 @@ class Transform extends new Record(DEFAULT_PROPERTIES) { * Add a step-creating method for each of the transforms. */ -TRANSFORMS.forEach((type) => { +Object.keys(Transforms).forEach((type) => { Transform.prototype[type] = function (...args) { let transform = this let { steps } = transform diff --git a/lib/models/transforms.js b/lib/models/transforms.js deleted file mode 100644 index a7c72904f..000000000 --- a/lib/models/transforms.js +++ /dev/null @@ -1,1182 +0,0 @@ - -import Block from './block' -import Character from './character' -import Data from './data' -import Document from './document' -import Inline from './inline' -import Mark from './mark' -import Selection from './selection' -import Text from './text' -import typeOf from 'type-of' -import uid from '../utils/uid' -import { List, Map, Set } from 'immutable' - -/** - * Transforms. - * - * These are pulled out into their own file because they can get complex. - */ - -const Transforms = { - - /** - * Add a new `mark` to the characters at `range`. - * - * @param {Selection} range - * @param {Mark or String or Object} mark - * @return {Node} node - */ - - addMarkAtRange(range, mark) { - mark = normalizeMark(mark) - let node = this - - // When the range is collapsed, do nothing. - if (range.isCollapsed) return node - - // Otherwise, find each of the text nodes within the range. - const { startKey, startOffset, endKey, endOffset } = range - let texts = node.getTextsAtRange(range) - - // Apply the mark to each of the text nodes's matching characters. - texts = texts.map((text) => { - let characters = text.characters.map((char, i) => { - if (!isInRange(i, text, range)) return char - let { marks } = char - marks = marks.add(mark) - return char.merge({ marks }) - }) - - return text.merge({ characters }) - }) - - // Update each of the text nodes. - texts.forEach((text) => { - node = node.updateDescendant(text) - }) - - return node - }, - - /** - * Delete everything in a `range`. - * - * @param {Selection} range - * @return {Node} node - */ - - deleteAtRange(range) { - if (range.isCollapsed) return this - - let node = this - - // Make sure the children exist. - const { startKey, startOffset, endKey, endOffset } = range - node.assertDescendant(startKey) - node.assertDescendant(endKey) - - // If the start and end nodes are the same, just remove characters. - if (startKey == endKey) { - let text = node.getDescendant(startKey) - text = text.removeCharacters(startOffset, endOffset) - node = node.updateDescendant(text) - return node.normalize() - } - - // Split the blocks and determine the edge boundaries. - const start = range.collapseToStart() - const end = range.collapseToEnd() - let ancestor = node.getCommonAncestor(startKey, endKey) - const isAncestor = ancestor == node - - ancestor = ancestor.splitBlockAtRange(start, Infinity) - ancestor = ancestor.splitBlockAtRange(end, Infinity) - - const startText = ancestor.getDescendant(startKey) - const startEdgeText = ancestor.getNextText(startKey) - - const endText = ancestor.getNextText(endKey) - const endEdgeText = ancestor.getDescendant(endKey) - - // Remove the new blocks inside the edges. - const startEdgeBlock = ancestor.getFurthestBlock(startEdgeText) - const endEdgeBlock = ancestor.getFurthestBlock(endEdgeText) - - const nodes = ancestor.nodes - .takeUntil(n => n == startEdgeBlock) - .concat(ancestor.nodes.skipUntil(n => n == endEdgeBlock).rest()) - - ancestor = ancestor.merge({ nodes }) - - // Take the end edge's inline nodes and move them to the start edge. - let startBlock = ancestor.getClosestBlock(startText) - let endBlock = ancestor.getClosestBlock(endText) - - const startNodes = startBlock.nodes.concat(endBlock.nodes) - startBlock = startBlock.merge({ nodes: startNodes }) - ancestor = ancestor.updateDescendant(startBlock) - - // While the end child is an only child, remove the block it's in. - let endParent = ancestor.getClosestBlock(endBlock) - - while (endParent && endParent.nodes.size == 1) { - endBlock = endParent - endParent = ancestor.getClosestBlock(endParent) - } - - ancestor = ancestor.removeDescendant(endBlock) - - // Update the node. - node = isAncestor - ? ancestor - : node.updateDescendant(ancestor) - - // Normalize the adjacent text nodes. - return node.normalize() - }, - - /** - * Delete backward `n` characters at a `range`. - * - * @param {Selection} range - * @param {Number} n (optional) - * @return {Node} node - */ - - deleteBackwardAtRange(range, n = 1) { - let node = this - const { startKey, startOffset } = range - - // When the range is still expanded, just do a regular delete. - if (range.isExpanded) return node.deleteAtRange(range) - - // When collapsed at the start of the node, there's nothing to do. - if (range.isAtStartOf(node)) return node - - // When collapsed in a void node, remove that node. - const block = node.getClosestBlock(startKey) - if (block && block.isVoid) return node.removeDescendant(block) - - const inline = node.getClosestInline(startKey) - if (inline && inline.isVoid) return node.removeDescendant(inline) - - // When at start of a text node, merge forwards into the next text node. - const startNode = node.getDescendant(startKey) - - if (range.isAtStartOf(startNode)) { - const previous = node.getPreviousText(startNode) - - // If the previous descendant is void, remove it. - const prevBlock = node.getClosestBlock(previous) - if (prevBlock && prevBlock.isVoid) return node.removeDescendant(prevBlock) - - const prevInline = node.getClosestInline(previous) - if (prevInline && prevInline.isVoid) return node.removeDescendant(prevInline) - - range = range.extendToEndOf(previous) - range = range.normalize(node) - return node.deleteAtRange(range) - } - - // Otherwise, remove `n` characters behind of the cursor. - range = range.extendBackward(n) - range = range.normalize(node) - return node.deleteAtRange(range) - }, - - /** - * Delete forward `n` characters at a `range`. - * - * @param {Selection} range - * @param {Number} n (optional) - * @return {Node} node - */ - - deleteForwardAtRange(range, n = 1) { - let node = this - const { startKey } = range - - // When the range is still expanded, just do a regular delete. - if (range.isExpanded) return node.deleteAtRange(range) - - // When collapsed at the end of the node, there's nothing to do. - if (range.isAtEndOf(node)) return node - - // When collapsed in a void node, remove that node. - const block = node.getClosestBlock(startKey) - if (block && block.isVoid) return node.removeDescendant(block) - - const inline = node.getClosestInline(startKey) - if (inline && inline.isVoid) return node.removeDescendant(inline) - - // When at end of a text node, merge forwards into the next text node. - const startNode = node.getDescendant(startKey) - if (range.isAtEndOf(startNode)) { - const next = node.getNextText(startNode) - range = range.extendToStartOf(next) - range = range.normalize(node) - return node.deleteAtRange(range) - } - - // Otherwise, remove `n` characters ahead of the cursor. - range = range.extendForward(n) - range = range.normalize(node) - return node.deleteAtRange(range) - }, - - /** - * Insert a `block` node at `range`. - * - * @param {Selection} range - * @param {Block or String or Object} block - * @return {Node} node - */ - - insertBlockAtRange(range, block) { - block = normalizeBlock(block) - let node = this - - // If expanded, delete the range first. - if (range.isExpanded) { - node = node.deleteAtRange(range) - range = range.collapseToStart() - } - - const { startKey, startOffset } = range - let startBlock = node.getClosestBlock(startKey) - let parent = node.getParent(startBlock) - let nodes = Block.createList([block]) - const isParent = parent == node - - // If the start block is void, insert after it. - if (startBlock.isVoid) { - parent = parent.insertChildrenAfter(startBlock, nodes) - } - - // If the block is empty, replace it. - else if (startBlock.isEmpty) { - parent = parent.insertChildrenAfter(startBlock, nodes) - parent = parent.removeDescendant(startBlock) - } - - // If the range is at the start of the block, insert before. - else if (range.isAtStartOf(startBlock)) { - parent = parent.insertChildrenBefore(startBlock, nodes) - } - - // If the range is at the end of the block, insert after. - else if (range.isAtEndOf(startBlock)) { - parent = parent.insertChildrenAfter(startBlock, nodes) - } - - // Otherwise, split the block and insert between. - else { - node = node.splitBlockAtRange(range) - parent = node.getParent(startBlock) - startBlock = node.getClosestBlock(startKey) - nodes = parent.nodes.takeUntil(n => n == startBlock) - .push(startBlock) - .push(block) - .concat(parent.nodes.skipUntil(n => n == startBlock).rest()) - parent = parent.merge({ nodes }) - } - - node = isParent - ? parent - : node.updateDescendant(parent) - - return node.normalize() - }, - - /** - * Insert a `fragment` at a `range`. - * - * @param {Selection} range - * @param {Document} fragment - * @return {Node} node - */ - - insertFragmentAtRange(range, fragment) { - range = range.normalize(this) - let node = this - - // If the range is expanded, delete first. - if (range.isExpanded) { - node = node.deleteAtRange(range) - range = range.collapseToStart() - } - - // If the fragment is empty, do nothing. - if (!fragment.length) return node - - // Make sure each node in the fragment has a unique key. - fragment = fragment.mapDescendants(child => child.set('key', uid())) - - // Split the inlines if need be. - if (!node.isInlineSplitAtRange(range)) { - node = node.splitInlineAtRange(range) - } - - // Determine the start and next children to insert into. - const { startKey, endKey } = range - let block = node.getClosestBlock(startKey) - let start = node.getDescendant(startKey) - let startChild - let nextChild - - if (range.isAtStartOf(node)) { - nextChild = node.getClosestBlock(node.getTexts().first()) - } - - if (range.isAtStartOf(block)) { - nextChild = block.getHighestChild(block.getTexts().first()) - } - - else if (range.isAtStartOf(start)) { - startChild = block.getHighestChild(block.getPreviousText(start)) - nextChild = block.getNextSibling(startChild) - } - - else { - startChild = block.getHighestChild(start) - nextChild = block.getNextSibling(startChild) - } - - // Get the first and last block of the fragment. - const blocks = fragment.getBlocks() - const firstBlock = blocks.first() - let lastBlock = blocks.last() - - // If the block is empty, merge in the first block's type and data. - if (block.length == 0) { - block = block.merge({ - type: firstBlock.type, - data: firstBlock.data - }) - } - - // Insert the first blocks nodes into the starting block. - if (startChild) { - block = block.insertChildrenAfter(startChild, firstBlock.nodes) - } else { - block = block.insertChildrenBefore(nextChild, firstBlock.nodes) - } - - node = node.updateDescendant(block) - - // If there are no other siblings, that's it. - if (firstBlock == lastBlock) return node.normalize() - - // Otherwise, remove the fragment's first block's highest solo parent... - let highestParent = fragment.getHighestOnlyChildParent(firstBlock) - fragment = fragment.removeDescendant(highestParent || firstBlock) - - // Then, add the inlines after the cursor from the current block to the - // start of the last block in the fragment. - if (nextChild) { - lastBlock = lastBlock.concatChildren(block.getChildrenAfterIncluding(nextChild)) - fragment = fragment.updateDescendant(lastBlock) - - block = block.removeChildrenAfterIncluding(nextChild) - node = node.updateDescendant(block) - } - - - // Finally, add the fragment's children after the block. - node = node.insertChildrenAfter(block, fragment.nodes) - return node.normalize() - }, - - /** - * Insert an `inline` node at `range`. - * - * @param {Selection} range - * @param {Inline or String or Object} inline - * @return {Node} node - */ - - insertInlineAtRange(range, inline) { - inline = normalizeInline(inline) - let node = this - - // If expanded, delete the range first. - if (range.isExpanded) { - node = node.deleteAtRange(range) - range = range.collapseToStart() - } - - const { startKey, endKey, startOffset, endOffset } = range - - // If the range is inside a void, abort. - const startBlock = node.getClosestBlock(startKey) - if (startBlock && startBlock.isVoid) return node - - const startInline = node.getClosestInline(startKey) - if (startInline && startInline.isVoid) return node - - // Split the text nodes at the cursor. - node = node.splitTextAtRange(range) - - // Insert the inline between the split text nodes. - const startText = node.getDescendant(startKey) - let parent = node.getParent(startKey) - const nodes = parent.nodes.takeUntil(n => n == startText) - .push(startText) - .push(inline) - .concat(parent.nodes.skipUntil(n => n == startText).rest()) - - parent = parent.merge({ nodes }) - node = node.updateDescendant(parent) - return node.normalize() - }, - - /** - * Insert text `string` at a `range`, with optional `marks`. - * - * @param {Selection} range - * @param {String} string - * @param {Set} marks (optional) - * @return {Node} node - */ - - insertTextAtRange(range, string, marks) { - let node = this - - // If inside a void node, do nothing. - const { startKey, startOffset } = range - const isVoid = node.hasVoidParent(startKey) - if (isVoid) return node - - // When still expanded, remove the current range first. - if (range.isExpanded) { - node = node.deleteAtRange(range) - range = range.collapseToStart() - } - - // Insert text at the range's offset. - let text = node.getDescendant(startKey) - text = text.insertText(startOffset, string, marks) - node = node.updateDescendant(text) - - return node - }, - - /** - * Remove an existing `mark` to the characters at `range`. - * - * @param {Selection} range - * @param {Mark or String} mark (optional) - * @return {Node} node - */ - - removeMarkAtRange(range, mark) { - mark = normalizeMark(mark) - let node = this - - // When the range is collapsed, do nothing. - if (range.isCollapsed) return node - - // Otherwise, find each of the text nodes within the range. - let texts = node.getTextsAtRange(range) - - // Apply the mark to each of the text nodes's matching characters. - texts = texts.map((text) => { - let characters = text.characters.map((char, i) => { - if (!isInRange(i, text, range)) return char - let { marks } = char - marks = mark - ? marks.remove(mark) - : marks.clear() - return char.merge({ marks }) - }) - - return text.merge({ characters }) - }) - - // Update each of the text nodes. - texts.forEach((text) => { - node = node.updateDescendant(text) - }) - - return node - }, - - /** - * Remove a node by `key`. - * - * @param {String} key - * @return {Node} node - */ - - removeNodeByKey(key) { - return this - .removeDescendant(key) - .normalize() - }, - - /** - * Set the `properties` of block nodes in a `range`. - * - * @param {Selection} range - * @param {Object or String} properties - * @return {Node} node - */ - - setBlockAtRange(range, properties = {}) { - properties = normalizeProperties(properties) - let node = this - const blocks = node.getBlocksAtRange(range) - - blocks.forEach((block) => { - block = block.merge(properties) - node = node.updateDescendant(block) - }) - - return node.normalize() - }, - - /** - * Set the `properties` of inline nodes in a `range`. - * - * @param {Selection} range - * @param {Object or String} properties - * @return {Node} node - */ - - setInlineAtRange(range, properties = {}) { - properties = normalizeProperties(properties) - let node = this - const inlines = node.getInlinesAtRange(range) - - inlines.forEach((inline) => { - inline = inline.merge(properties) - node = node.updateDescendant(inline) - }) - - return node.normalize() - }, - - /** - * Set `properties` on a node by `key`. - * - * @param {String} key - * @param {Object or String} properties - * @return {Node} node - */ - - setNodeByKey(key, properties) { - properties = normalizeProperties(properties) - let node = this - let descendant = node.assertDescendant(key) - descendant = descendant.merge(properties) - node = node.updateDescendant(descendant) - return node - }, - - /** - * Split the block nodes at a `range`, to optional `depth`. - * - * @param {Selection} range - * @param {Number} depth (optional) - * @return {Node} node - */ - - splitBlockAtRange(range, depth = 1) { - let node = this - - // If the range is expanded, remove it first. - if (range.isExpanded) { - node = node.deleteAtRange(range) - range = range.collapseToStart() - } - - // Split the inline nodes at the range. - node = node.splitInlineAtRange(range) - - // Find the highest inline elements that were split. - const { startKey } = range - const firstText = node.getDescendant(startKey) - const secondText = node.getNextText(startKey) - let firstChild = node.getFurthestInline(firstText) || firstText - let secondChild = node.getFurthestInline(secondText) || secondText - let parent = node.getClosestBlock(firstChild) - let firstChildren - let secondChildren - let d = 0 - - // While the parent is a block, split the block nodes. - while (parent && d < depth) { - firstChildren = parent.nodes.takeUntil(n => n == firstChild).push(firstChild) - secondChildren = parent.nodes.skipUntil(n => n == secondChild) - firstChild = parent.merge({ nodes: firstChildren }) - secondChild = Block.create({ - nodes: secondChildren, - type: parent.type, - data: parent.data - }) - - // Add the new children. - const grandparent = node.getParent(parent) - const nodes = grandparent.nodes - .takeUntil(n => n.key == firstChild.key) - .push(firstChild) - .push(secondChild) - .concat(grandparent.nodes.skipUntil(n => n.key == firstChild.key).rest()) - - // Update the grandparent. - node = grandparent == node - ? node.merge({ nodes }) - : node.updateDescendant(grandparent.merge({ nodes })) - - d++ - parent = node.getClosestBlock(firstChild) - } - - return node - }, - - /** - * Split the inline nodes at a `range`, to optional `depth`. - * - * @param {Selection} range - * @param {Number} depth (optiona) - * @return {Node} node - */ - - splitInlineAtRange(range, depth = Infinity) { - let node = this - - // If the range is expanded, remove it first. - if (range.isExpanded) { - node = node.deleteAtRange(range) - range = range.collapseToStart() - } - - // First split the text nodes. - node = node.splitTextAtRange(range) - - // Find the children that were split. - const { startKey } = range - let firstChild = node.getDescendant(startKey) - let secondChild = node.getNextText(firstChild) - let parent = node.getClosestInline(firstChild) - let d = 0 - - // While the parent is an inline parent, split the inline nodes. - while (parent && d < depth) { - firstChild = parent.merge({ nodes: Inline.createList([firstChild]) }) - secondChild = Inline.create({ - nodes: [secondChild], - type: parent.type, - data: parent.data - }) - - // Split the children. - const grandparent = node.getParent(parent) - const nodes = grandparent.nodes - .takeUntil(n => n.key == firstChild.key) - .push(firstChild) - .push(secondChild) - .concat(grandparent.nodes.skipUntil(n => n.key == firstChild.key).rest()) - - // Update the grandparent. - node = grandparent == node - ? node.merge({ nodes }) - : node.updateDescendant(grandparent.merge({ nodes })) - - d++ - parent = node.getClosestInline(firstChild) - } - - return node - }, - - /** - * Split the text nodes at a `range`. - * - * @param {Selection} range - * @return {Node} node - */ - - splitTextAtRange(range) { - let node = this - - // If the range is expanded, remove it first. - if (range.isExpanded) { - node = node.deleteAtRange(range) - range = range.collapseToStart() - } - - // Split the text node's characters. - const { startKey, startOffset } = range - const text = node.getDescendant(startKey) - const { characters } = text - const firstChars = characters.take(startOffset) - const secondChars = characters.skip(startOffset) - let firstChild = text.merge({ characters: firstChars }) - let secondChild = Text.create({ characters: secondChars }) - - // Split the text nodes. - let parent = node.getParent(text) - const nodes = parent.nodes - .takeUntil(c => c.key == firstChild.key) - .push(firstChild) - .push(secondChild) - .concat(parent.nodes.skipUntil(n => n.key == firstChild.key).rest()) - - // Update the nodes. - parent = parent.merge({ nodes }) - node = node.updateDescendant(parent) - return node - }, - - /** - * Add or remove a `mark` from the characters at `range`, depending on whether - * it's already there. - * - * @param {Selection} range - * @param {Mark or String} mark (optional) - * @return {Node} node - */ - - toggleMarkAtRange(range, mark) { - mark = normalizeMark(mark) - let node = this - - // When the range is collapsed, do nothing. - if (range.isCollapsed) return node - - // Check if the mark exists in the range already. - const marks = node.getMarksAtRange(range) - const exists = marks.some(m => m.equals(mark)) - - return exists - ? node.removeMarkAtRange(range, mark) - : node.addMarkAtRange(range, mark) - }, - - /** - * Unwrap all of the block nodes in a `range` from a block with `properties`. - * - * @param {Selection} range - * @param {String or Object} properties - * @return {Node} node - */ - - unwrapBlockAtRange(range, properties) { - properties = normalizeProperties(properties) - let node = this - - // Get the deepest blocks in the range. - const blocks = node.getBlocksAtRange(range) - - // Get the matching wrapper blocks. - const wrappers = blocks.reduce((memo, text) => { - const match = node.getClosest(text, (parent) => { - if (parent.kind != 'block') return false - if (properties.type && parent.type != properties.type) return false - if (properties.data && !parent.data.isSuperset(properties.data)) return false - return true - }) - - if (match) memo = memo.add(match) - return memo - }, new Set()) - - // For each of the wrapper blocks... - wrappers.forEach((wrapper) => { - const first = wrapper.nodes.first() - const last = wrapper.nodes.last() - const parent = node.getParent(wrapper) - - // Get the wrapped direct children. - const children = wrapper.nodes.filter((child) => { - return blocks.some(block => child == block || child.hasDescendant(block)) - }) - - // Determine what the new nodes should be... - const firstMatch = children.first() - const lastMatch = children.last() - let nodes - - // If the first and last both match, remove the wrapper completely. - if (first == firstMatch && last == lastMatch) { - nodes = parent.nodes.takeUntil(n => n == wrapper) - .concat(wrapper.nodes) - .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) - } - - // If only the last child matches, move the last nodes. - else if (last == lastMatch) { - const remain = wrapper.nodes.takeUntil(n => n == firstMatch) - const updated = wrapper.merge({ nodes: remain }) - nodes = parent.nodes.takeUntil(n => n == wrapper) - .push(updated) - .concat(children) - .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) - } - - // If only the first child matches, move the first ones. - else if (first == firstMatch) { - const remain = wrapper.nodes.skipUntil(n => n == lastMatch).rest() - const updated = wrapper.merge({ nodes: remain }) - nodes = parent.nodes.takeUntil(n => n == wrapper) - .concat(children) - .push(updated) - .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) - } - - // Otherwise, move the middle ones. - else { - const firsts = wrapper.nodes.takeUntil(n => n == firstMatch) - const lasts = wrapper.nodes.skipUntil(n => n == lastMatch).rest() - const updatedFirst = wrapper.merge({ nodes: firsts }) - const updatedLast = wrapper.merge({ nodes: lasts }) - nodes = parent.nodes.takeUntil(n => n == wrapper) - .push(updatedFirst) - .concat(children) - .push(updatedLast) - .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) - } - - node = parent == node - ? node.merge({ nodes }) - : node.updateDescendant(parent.merge({ nodes })) - }) - - return node.normalize() - }, - - /** - * Unwrap the inline nodes in a `range` from an inline with `properties`. - * - * @param {Selection} range - * @param {String or Object} properties - * @return {Node} node - */ - - unwrapInlineAtRange(range, properties) { - properties = normalizeProperties(properties) - let node = this - let blocks = node.getInlinesAtRange(range) - - // Find the closest matching inline wrappers of each text node. - const texts = this.getTexts() - const wrappers = texts.reduce((memo, text) => { - const match = node.getClosest(text, (parent) => { - if (parent.kind != 'inline') return false - if (properties.type && parent.type != properties.type) return false - if (properties.data && !parent.data.isSuperset(properties.data)) return false - return true - }) - - if (match) memo = memo.add(match) - return memo - }, new Set()) - - // Replace each of the wrappers with their child nodes. - wrappers.forEach((wrapper) => { - const parent = node.getParent(wrapper) - - // Replace the wrapper in the parent's nodes with the block. - const nodes = parent.nodes.takeUntil(n => n == wrapper) - .concat(wrapper.nodes) - .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) - - // Update the parent. - node = parent == node - ? node.merge({ nodes }) - : node.updateDescendant(parent.merge({ nodes })) - }) - - return node.normalize() - }, - - /** - * Wrap all of the blocks in a `range` in a new block with `properties`. - * - * @param {Selection} range - * @param {String or Object} properties - * @return {Node} node - */ - - wrapBlockAtRange(range, properties) { - properties = normalizeProperties(properties) - let node = this - - // Get the block nodes, sorted by depth. - const blocks = node.getBlocksAtRange(range) - const sorted = blocks.sort((a, b) => { - const da = node.getDepth(a) - const db = node.getDepth(b) - if (da == db) return 0 - else if (da > db) return -1 - else return 1 - }) - - // Get the lowest common siblings, relative to the highest block. - const highest = sorted.first() - const depth = node.getDepth(highest) - const siblings = blocks.reduce((memo, block) => { - const sibling = node.getDepth(block) == depth - ? block - : node.getClosest(block, (p) => node.getDepth(p) == depth) - memo = memo.push(sibling) - return memo - }, Block.createList()) - - // Wrap the siblings in a new block. - const wrapper = Block.create({ - nodes: siblings, - type: properties.type, - data: properties.data - }) - - // Replace the siblings with the wrapper. - const first = siblings.first() - const last = siblings.last() - const parent = node.getParent(highest) - const nodes = parent.nodes - .takeUntil(n => n == first) - .push(wrapper) - .concat(parent.nodes.skipUntil(n => n == last).rest()) - - // Update the parent. - node = parent == node - ? node.merge({ nodes }) - : node.updateDescendant(parent.merge({ nodes })) - - return node - }, - - /** - * Wrap the text and inlines in a `range` in a new inline with `properties`. - * - * @param {Selection} range - * @param {String or Object} properties - * @return {Node} node - */ - - wrapInlineAtRange(range, properties) { - properties = normalizeProperties(properties) - let node = this - - // If collapsed, there's nothing to wrap. - if (range.isCollapsed) return node - - // Split at the start of the range. - const start = range.collapseToStart() - node = node.splitInlineAtRange(start) - - // Determine the new end of the range, and split there. - const { startKey, startOffset, endKey, endOffset } = range - const firstNode = node.getDescendant(startKey) - const nextNode = node.getNextText(startKey) - const end = startKey != endKey - ? range.collapseToEnd() - : Selection.create({ - anchorKey: nextNode.key, - anchorOffset: endOffset - startOffset, - focusKey: nextNode.key, - focusOffset: endOffset - startOffset - }) - - node = node.splitInlineAtRange(end) - - // Calculate the new range to wrap around. - const endNode = node.getDescendant(end.anchorKey) - range = Selection.create({ - anchorKey: nextNode.key, - anchorOffset: 0, - focusKey: endNode.key, - focusOffset: endNode.length - }) - - // Get the furthest inline nodes in the range. - const texts = node.getTextsAtRange(range) - const children = texts.map(text => node.getFurthestInline(text) || text) - - // Iterate each of the child nodes, wrapping them. - children.forEach((child) => { - const wrapper = Inline.create({ - nodes: [child], - type: properties.type, - data: properties.data - }) - - // Replace the child in it's parent with the wrapper. - const parent = node.getParent(child) - const nodes = parent.nodes.takeUntil(n => n == child) - .push(wrapper) - .concat(parent.nodes.skipUntil(n => n == child).rest()) - - // Update the parent. - node = parent == node - ? node.merge({ nodes }) - : node.updateDescendant(parent.merge({ nodes })) - }) - - return node.normalize() - }, - - /** - * Wrap the text in a `range` in a prefix/suffix. - * - * @param {Selection} range - * @param {String} prefix - * @param {String} suffix - * @return {Node} node - */ - - wrapTextAtRange(range, prefix, suffix = prefix) { - let node = this - - // Insert text at the starting edge. - const { startKey, endKey } = range - const start = range.collapseToStart() - node = node.insertTextAtRange(start, prefix) - - // Determine the new ending edge, and insert text there. - let end = range.collapseToEnd() - if (startKey == endKey) end = end.moveForward(prefix.length) - node = node.insertTextAtRange(end, suffix) - - return node - } - -} - -/** - * Check if an `index` of a `text` node is in a `range`. - * - * @param {Number} index - * @param {Text} text - * @param {Selection} range - * @return {Set} characters - */ - -function isInRange(index, text, range) { - const { startKey, startOffset, endKey, endOffset } = range - let matcher - - if (text.key == startKey && text.key == endKey) { - return startOffset <= index && index < endOffset - } else if (text.key == startKey) { - return startOffset <= index - } else if (text.key == endKey) { - return index < endOffset - } else { - return true - } -} - -/** - * Normalize a `mark` argument, which can be a string or plain object too. - * - * @param {Mark or String or Object} mark - * @return {Mark} - */ - -function normalizeMark(mark) { - if (mark instanceof Mark) return mark - - const type = typeOf(mark) - - switch (type) { - case 'string': - case 'object': { - return Mark.create(normalizeProperties(mark)) - } - default: { - throw new Error(`A \`mark\` argument must be a mark, an object or a string, but you passed: "${type}".`) - } - } -} - -/** - * Normalize a `block` argument, which can be a string or plain object too. - * - * @param {Block or String or Object} block - * @return {Block} - */ - -function normalizeBlock(block) { - if (block instanceof Block) return block - - const type = typeOf(block) - - switch (type) { - case 'string': - case 'object': { - return Block.create(normalizeProperties(block)) - } - default: { - throw new Error(`A \`block\` argument must be a block, an object or a string, but you passed: "${type}".`) - } - } -} - -/** - * Normalize an `inline` argument, which can be a string or plain object too. - * - * @param {Inline or String or Object} inline - * @return {Inline} - */ - -function normalizeInline(inline) { - if (inline instanceof Inline) return inline - - const type = typeOf(inline) - - switch (type) { - case 'string': - case 'object': { - return Inline.create(normalizeProperties(inline)) - } - default: { - throw new Error(`An \`inline\` argument must be an inline, an object or a string, but you passed: "${type}".`) - } - } -} - -/** - * Normalize the `properties` of a node or mark, which can be either a type - * string or a dictionary of properties. If it's a dictionary, `data` is - * optional and shouldn't be set if null or undefined. - * - * @param {String or Object} properties - * @return {Object} - */ - -function normalizeProperties(properties = {}) { - const ret = {} - const type = typeOf(properties) - - switch (type) { - case 'string': { - ret.type = properties - break - } - case 'object': { - for (const key in properties) { - if (key == 'data') { - if (properties[key] != null) ret[key] = Data.create(properties[key]) - } else { - ret[key] = properties[key] - } - } - break - } - default: { - throw new Error(`A \`properties\` argument must be an object or a string, but you passed: "${type}".`) - } - } - - return ret -} - -/** - * Export. - */ - -export default Transforms diff --git a/lib/transforms/at-current-range.js b/lib/transforms/at-current-range.js new file mode 100644 index 000000000..9492bdbd4 --- /dev/null +++ b/lib/transforms/at-current-range.js @@ -0,0 +1,612 @@ + +import normalizeMark from '../utils/normalize-mark' +import { + addMarkAtRange, + deleteAtRange, + deleteBackwardAtRange, + deleteForwardAtRange, + insertBlockAtRange, + insertFragmentAtRange, + insertInlineAtRange, + insertTextAtRange, + removeMarkAtRange, + setBlockAtRange, + setInlineAtRange, + splitBlockAtRange, + splitInlineAtRange, + splitTextAtRange, + toggleMarkAtRange, + unwrapBlockAtRange, + unwrapInlineAtRange, + wrapBlockAtRange, + wrapInlineAtRange, + wrapTextAtRange, +} from './at-range' + +/** + * Add a `mark` to the characters in the current selection. + * + * @param {State} state + * @param {Mark} mark + * @return {State} state + */ + +export function addMark(state, mark) { + mark = normalizeMark(mark) + let { cursorMarks, document, selection } = state + + // If the selection is collapsed, add the mark to the cursor instead. + if (selection.isCollapsed) { + const marks = document.getMarksAtRange(selection) + state = state.merge({ cursorMarks: marks.add(mark) }) + return state + } + + return addMarkAtRange(state, selection, mark) +} + +/** + * Delete at the current selection. + * + * @param {State} state + * @return {State} + */ + +export function _delete(state) { + let { document, selection } = state + let after + + // When collapsed, there's nothing to do. + if (selection.isCollapsed) return state + + // Determine what the selection will be after deleting. + const { startText } = state + const { startKey, startOffset, endKey, endOffset } = selection + const block = document.getClosestBlock(startText) + const highest = block.getHighestChild(startText) + const previous = block.getPreviousSibling(highest) + const next = block.getNextSibling(highest) + + if ( + previous && + startOffset == 0 && + (endKey != startKey || endOffset == startText.length) + ) { + if (previous.kind == 'text') { + if (next && next.kind == 'text') { + after = selection.merge({ + anchorKey: previous.key, + anchorOffset: previous.length, + focusKey: previous.key, + focusOffset: previous.length + }) + } else { + after = selection.collapseToEndOf(previous) + } + } else { + const last = previous.getTexts().last() + after = selection.collapseToEndOf(last) + } + } + + else { + after = selection.collapseToStart() + } + + // Delete and update the selection. + state = deleteAtRange(state, selection) + state = state.merge({ selection: after }) + return state +} + +/** + * Delete backward `n` characters at the current selection. + * + * @param {State} state + * @param {Number} n (optional) + * @return {State} + */ + +export function deleteBackward(state, n = 1) { + let { document, selection } = state + let after = selection + + // Determine what the selection should be after deleting. + const { startKey } = selection + const startNode = document.getDescendant(startKey) + + if (selection.isExpanded) { + after = selection.collapseToStart() + } + + else if (selection.isAtStartOf(document)) { + after = selection + } + + else if (selection.isAtStartOf(startNode)) { + const previous = document.getPreviousText(startNode) + const prevBlock = document.getClosestBlock(previous) + const prevInline = document.getClosestInline(previous) + + if (prevBlock && prevBlock.isVoid) { + after = selection + } else if (prevInline && prevInline.isVoid) { + after = selection + } else { + after = selection.collapseToEndOf(previous) + } + } + + else if (selection.isAtEndOf(startNode) && startNode.length == 1) { + const block = document.getClosestBlock(startKey) + const highest = block.getHighestChild(startKey) + const previous = block.getPreviousSibling(highest) + const next = block.getNextSibling(highest) + + if (previous) { + if (previous.kind == 'text') { + if (next && next.kind == 'text') { + after = selection.merge({ + anchorKey: previous.key, + anchorOffset: previous.length, + focusKey: previous.key, + focusOffset: previous.length + }) + } else { + after = selection.collapseToEndOf(previous) + } + } else { + const last = previous.getTexts().last() + after = selection.collapseToEndOf(last) + } + } else { + after = selection.moveBackward(n) + } + } + + else { + after = selection.moveBackward(n) + } + + // Delete backward and then update the selection. + state = deleteBackwardAtRange(state, selection, n) + state = state.merge({ selection: after }) + return state +} + +/** + * Delete forward `n` characters at the current selection. + * + * @param {State} state + * @param {Number} n (optional) + * @return {State} + */ + +export function deleteForward(state, n = 1) { + let { document, selection, startText } = state + let { startKey, startOffset } = selection + let after = selection + + // Determine what the selection should be after deleting. + const block = document.getClosestBlock(startKey) + const inline = document.getClosestInline(startKey) + const highest = block.getHighestChild(startKey) + const previous = block.getPreviousSibling(highest) + const next = block.getNextSibling(highest) + + if (selection.isExpanded) { + after = selection.collapseToStart() + } + + else if ((block && block.isVoid) || (inline && inline.isVoid)) { + const nextText = document.getNextText(startKey) + const prevText = document.getPreviousText(startKey) + after = next + ? selection.collapseToStartOf(nextText) + : selection.collapseToEndOf(prevText) + } + + else if (previous && startOffset == 0 && startText.length == 1) { + if (previous.kind == 'text') { + if (next && next.kind == 'text') { + after = selection.merge({ + anchorKey: previous.key, + anchorOffset: previous.length, + focusKey: previous.key, + focusOffset: previous.length + }) + } else { + after = selection.collapseToEndOf(previous) + } + } else { + const last = previous.getTexts().last() + after = selection.collapseToEndOf(last) + } + } + + // Delete forward and then update the selection. + state = deleteForwardAtRange(state, selection, n) + state = state.merge({ selection: after }) + return state +} + +/** + * Insert a `block` at the current selection. + * + * @param {State} state + * @param {String || Object || Block} block + * @return {State} + */ + +export function insertBlock(state, block) { + let { document, selection } = state + const keys = document.getTexts().map(text => text.key) + + // Insert the block + state = insertBlockAtRange(state, selection, block) + document = state.document + selection = state.selection + + // Determine what the selection should be after inserting. + const text = document.getTexts().find(n => !keys.includes(n.key)) + selection = selection.collapseToEndOf(text) + + // Update the document and selection. + state = state.merge({ selection }) + return state +} + +/** + * Insert a `fragment` at the current selection. + * + * @param {State} state + * @param {Document} fragment + * @return {State} + */ + +export function insertFragment(state, fragment) { + let { document, selection } = state + let after = selection + + // If there's nothing in the fragment, do nothing. + if (!fragment.length) return state + + // Lookup some nodes for determining the selection next. + const lastText = fragment.getTexts().last() + const lastInline = fragment.getClosestInline(lastText) + const beforeTexts = document.getTexts() + + // Insert the fragment. + state = insertFragmentAtRange(state, selection, fragment) + document = state.document + selection = state.selection + + // Determine what the selection should be after inserting. + const keys = beforeTexts.map(text => text.key) + const text = document.getTexts().findLast(n => !keys.includes(n.key)) + const previousText = text ? document.getPreviousText(text) : null + + if (text && lastInline && previousText) { + after = selection.collapseToEndOf(previousText) + } + + else if (text && lastInline) { + after = selection.collapseToStart() + } + + else if (text) { + after = selection + .collapseToStartOf(text) + .moveForward(lastText.length) + } + + else { + after = selection + .collapseToStart() + .moveForward(lastText.length) + } + + // Update the document and selection. + selection = after + state = state.merge({ document, selection }) + return state +} + +/** + * Insert a `inline` at the current selection. + * + * @param {State} state + * @param {String || Object || Block} inline + * @return {State} + */ + +export function insertInline(state, inline) { + let { document, selection, startText } = state + const hasVoid = document.hasVoidParent(startText) + const keys = document.getTexts().map(text => text.key) + + // Insert the inline + state = insertInlineAtRange(state, selection, inline) + document = state.document + selection = state.selection + + // Determine what the selection should be after inserting. + if (hasVoid) { + selection = selection + } + + else { + const text = document.getTexts().find(n => !keys.includes(n.key)) + selection = selection.collapseToEndOf(text) + } + + // Update the document and selection. + state = state.merge({ document, selection }) + return state +} + +/** + * Insert a `text` string at the current selection. + * + * @param {State} state + * @param {String} text + * @param {Set} marks (optional) + * @return {State} + */ + +export function insertText(state, text, marks) { + let { cursorMarks, document, selection } = state + let after + const isVoid = document.hasVoidParent(state.startText) + + // Determine what the selection should be after inserting. + if (isVoid) { + after = selection + } + + else if (selection.isExpanded) { + after = selection.collapseToStart().moveForward(text.length) + } + + else { + after = selection.moveForward(text.length) + } + + // Insert the text and update the selection. + state = insertTextAtRange(state, selection, text, marks || cursorMarks) + state = state.merge({ selection: after }) + return state +} + + +/** + * Set `properties` of the block nodes in the current selection. + * + * @param {State} state + * @param {Object} properties + * @return {State} + */ + +export function setBlock(state, properties) { + return setBlockAtRange(state, state.selection, properties) +} + +/** + * Set `properties` of the inline nodes in the current selection. + * + * @param {State} state + * @param {Object} properties + * @return {State} + */ + +export function setInline(state, properties) { + return setInlineAtRange(state, state.selection, properties) +} + +/** + * Split the block node at the current selection, to optional `depth`. + * + * @param {State} state + * @param {Number} depth (optional) + * @return {State} + */ + +export function splitBlock(state, depth = 1) { + state = splitBlockAtRange(state, state.selection, depth) + let { document, selection } = state + + // Determine what the selection should be after splitting. + const { startKey } = selection + const startNode = document.getDescendant(startKey) + const nextNode = document.getNextText(startNode) + selection = selection.collapseToStartOf(nextNode) + state = state.merge({ selection }) + return state +} + +/** + * Split the inline nodes at the current selection, to optional `depth`. + * + * @param {State} state + * @param {Number} depth (optional) + * @return {State} + */ + +export function splitInline(state, depth = Infinity) { + let { document, selection } = state + + // Split the document. + state = splitInlineAtRange(state, selection, depth) + document = state.document + selection = state.selection + + // Determine what the selection should be after splitting. + const { startKey } = selection + const inlineParent = document.getClosestInline(startKey) + + if (inlineParent) { + const startNode = document.getDescendant(startKey) + const nextNode = document.getNextText(startNode) + selection = selection.collapseToStartOf(nextNode) + } + + state = state.merge({ document, selection }) + return state +} + +/** + * Remove a `mark` from the characters in the current selection. + * + * @param {State} state + * @param {Mark} mark + * @return {State} + */ + +export function removeMark(state, mark) { + mark = normalizeMark(mark) + let { cursorMarks, document, selection } = state + + // If the selection is collapsed, remove the mark from the cursor instead. + if (selection.isCollapsed) { + const marks = document.getMarksAtRange(selection) + state = state.merge({ cursorMarks: marks.remove(mark) }) + return state + } + + return removeMarkAtRange(state, state.selection, mark) +} + +/** + * Add or remove a `mark` from the characters in the current selection, + * depending on whether it's already there. + * + * @param {State} state + * @param {Mark} mark + * @return {State} + */ + +export function toggleMark(state, mark) { + mark = normalizeMark(mark) + const exists = state.marks.some(m => m.equals(mark)) + return exists + ? removeMark(state, mark) + : addMark(state, mark) +} + +/** + * Unwrap the current selection from a block parent with `properties`. + * + * @param {State} state + * @param {Object or String} properties + * @return {State} + */ + +export function unwrapBlock(state, properties) { + return unwrapBlockAtRange(state, state.selection, properties) +} + +/** + * Unwrap the current selection from an inline parent with `properties`. + * + * @param {State} state + * @param {Object or String} properties + * @return {State} + */ + +export function unwrapInline(state, properties) { + return unwrapInlineAtRange(state, state.selection, properties) +} + +/** + * Wrap the block nodes in the current selection with a new block node with + * `properties`. + * + * @param {State} state + * @param {Object or String} properties + * @return {State} + */ + +export function wrapBlock(state, properties) { + return wrapBlockAtRange(state, state.selection, properties) +} + +/** + * Wrap the current selection in new inline nodes with `properties`. + * + * @param {State} state + * @param {Object or String} properties + * @return {State} + */ + +export function wrapInline(state, properties) { + let { document, selection } = state + const { startKey } = selection + const previous = document.getPreviousText(startKey) + + state = wrapInlineAtRange(state, selection, properties) + document = state.document + selection = state.selection + + // Determine what the selection should be after wrapping. + if (selection.isCollapsed) { + selection = selection + } + + else if (selection.startOffset == 0) { + const text = previous + ? document.getNextText(previous) + : document.getTexts().first() + selection = selection.moveToRangeOf(text) + } + + else if (selection.startKey == selection.endKey) { + const text = document.getNextText(selection.startKey) + selection = selection.moveToRangeOf(text) + } + + else { + const anchor = document.getNextText(selection.anchorKey) + const focus = document.getDescendant(selection.focusKey) + selection = selection.merge({ + anchorKey: anchor.key, + anchorOffset: 0, + focusKey: focus.key, + focusOffset: selection.focusOffset + }) + } + + state = state.merge({ selection }) + return state +} + +/** + * Wrap the current selection with prefix/suffix. + * + * @param {State} state + * @param {String} prefix + * @param {String} suffix + * @return {State} + */ + +export function wrapText(state, prefix, suffix = prefix) { + let { document, selection } = state + let { anchorOffset, anchorKey, focusOffset, focusKey, isBackward } = selection + let after + + // Determine what the selection should be after wrapping. + if (anchorKey == focusKey) { + after = selection.moveForward(prefix.length) + } + + else { + after = selection.merge({ + anchorOffset: isBackward ? anchorOffset : anchorOffset + prefix.length, + focusOffset: isBackward ? focusOffset + prefix.length : focusOffset + }) + } + + // Wrap the text and update the state. + state = wrapTextAtRange(state, selection, prefix, suffix) + state = state.merge({ selection: after }) + return state +} diff --git a/lib/transforms/at-range.js b/lib/transforms/at-range.js new file mode 100644 index 000000000..ffa8104a8 --- /dev/null +++ b/lib/transforms/at-range.js @@ -0,0 +1,1117 @@ + +import Block from '../models/block' +import Inline from '../models/inline' +import Selection from '../models/selection' +import Text from '../models/text' +import isInRange from '../utils/is-in-range' +import normalizeBlock from '../utils/normalize-block' +import normalizeInline from '../utils/normalize-inline' +import normalizeMark from '../utils/normalize-mark' +import normalizeProperties from '../utils/normalize-node-or-mark-properties' +import uid from '../utils/uid' +import { Set } from 'immutable' + +/** + * Add a new `mark` to the characters at `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Mark || String || Object} mark + * @return {State} + */ + +export function addMarkAtRange(state, range, mark) { + let { document } = state + + // Normalize the mark. + mark = normalizeMark(mark) + + // When the range is collapsed, do nothing. + if (range.isCollapsed) return state + + // Otherwise, find each of the text nodes within the range. + const { startKey, startOffset, endKey, endOffset } = range + let texts = document.getTextsAtRange(range) + + // Apply the mark to each of the text nodes's matching characters. + texts = texts.map((text) => { + let characters = text.characters.map((char, i) => { + if (!isInRange(i, text, range)) return char + let { marks } = char + marks = marks.add(mark) + return char.merge({ marks }) + }) + + return text.merge({ characters }) + }) + + // Update each of the text nodes. + texts.forEach((text) => { + document = document.updateDescendant(text) + }) + + // Update the state. + state = state.merge({ document }) + return state +} + +/** + * Delete everything in a `range`. + * + * @param {State} state + * @param {Selection} range + * @return {State} + */ + +export function deleteAtRange(state, range) { + let { document } = state + + // If the range is collapsed, there's nothing to delete. + if (range.isCollapsed) return state + + // Make sure the children exist. + const { startKey, startOffset, endKey, endOffset } = range + document.assertDescendant(startKey) + document.assertDescendant(endKey) + + // If the start and end nodes are the same, just remove characters. + if (startKey == endKey) { + let text = document.getDescendant(startKey) + text = text.removeCharacters(startOffset, endOffset) + document = document.updateDescendant(text) + document = document.normalize() + state = state.merge({ document }) + return state + } + + // Split the blocks and determine the edge boundaries. + const start = range.collapseToStart() + const end = range.collapseToEnd() + let startBlock = document.getClosestBlock(startKey) + let endBlock = document.getClosestBlock(endKey) + const startDepth = document.getDepth(startBlock) + const endDepth = document.getDepth(endBlock) + + let ancestor = document.getCommonAncestor(startKey, endKey) + let isAncestor = ancestor == document + const ancestorDepth = isAncestor ? 0 : document.getDepth(ancestor) + + state = splitBlockAtRange(state, start, startDepth - ancestorDepth) + state = splitBlockAtRange(state, end, endDepth - ancestorDepth) + document = state.document + ancestor = document.getCommonAncestor(startKey, endKey) + + const startText = ancestor.getDescendant(startKey) + const startEdgeText = ancestor.getNextText(startKey) + + const endText = ancestor.getNextText(endKey) + const endEdgeText = ancestor.getDescendant(endKey) + + startBlock = document.getClosestBlock(startText) + endBlock = document.getClosestBlock(endText) + + // Remove the new blocks inside the edges. + const startEdgeBlock = ancestor.getFurthestBlock(startEdgeText) + const endEdgeBlock = ancestor.getFurthestBlock(endEdgeText) + + const nodes = ancestor.nodes + .takeUntil(n => n == startEdgeBlock) + .concat(ancestor.nodes.skipUntil(n => n == endEdgeBlock).rest()) + + ancestor = ancestor.merge({ nodes }) + + // Take the end edge's inline nodes and move them to the start edge. + const startNodes = startBlock.nodes.concat(endBlock.nodes) + startBlock = startBlock.merge({ nodes: startNodes }) + ancestor = ancestor.updateDescendant(startBlock) + + // While the end child is an only child, remove the block it's in. + let endParent = ancestor.getClosestBlock(endBlock) + + while (endParent && endParent.nodes.size == 1) { + endBlock = endParent + endParent = ancestor.getClosestBlock(endParent) + } + + ancestor = ancestor.removeDescendant(endBlock) + + // Update the document. + document = isAncestor + ? ancestor + : document.updateDescendant(ancestor) + + // Normalize the adjacent text nodes. + document = document.normalize() + + // Update the state. + state = state.merge({ document }) + return state +} + +/** + * Delete backward `n` characters at a `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Number} n (optional) + * @return {State} + */ + +export function deleteBackwardAtRange(state, range, n = 1) { + let { document } = state + const { startKey, startOffset } = range + + // When the range is still expanded, just do a regular delete. + if (range.isExpanded) return deleteAtRange(state, range) + + // When collapsed at the start of the node, there's nothing to do. + if (range.isAtStartOf(document)) return state + + // When collapsed in a void node, remove that node. + const block = document.getClosestBlock(startKey) + if (block && block.isVoid) { + document = document.removeDescendant(block) + state = state.merge({ document }) + return state + } + + const inline = document.getClosestInline(startKey) + if (inline && inline.isVoid) { + document = document.removeDescendant(inline) + state = state.merge({ document }) + return state + } + + // When at start of a text node, merge forwards into the next text node. + const startNode = document.getDescendant(startKey) + + if (range.isAtStartOf(startNode)) { + const previous = document.getPreviousText(startNode) + + // If the previous descendant is void, remove it. + const prevBlock = document.getClosestBlock(previous) + if (prevBlock && prevBlock.isVoid) { + document = document.removeDescendant(prevBlock) + state = state.merge({ document }) + return state + } + + const prevInline = document.getClosestInline(previous) + if (prevInline && prevInline.isVoid) { + document = document.removeDescendant(prevInline) + state = state.merge({ document }) + return state + } + + range = range.extendToEndOf(previous) + range = range.normalize(document) + return deleteAtRange(state, range) + } + + // Otherwise, remove `n` characters behind of the cursor. + range = range.extendBackward(n) + range = range.normalize(document) + return deleteAtRange(state, range) +} + +/** + * Delete forward `n` characters at a `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Number} n (optional) + * @return {State} + */ + +export function deleteForwardAtRange(state, range, n = 1) { + let { document } = state + const { startKey } = range + + // When the range is still expanded, just do a regular delete. + if (range.isExpanded) return deleteAtRange(state, range) + + // When collapsed at the end of the node, there's nothing to do. + if (range.isAtEndOf(document)) return state + + // When collapsed in a void node, remove that node. + const block = document.getClosestBlock(startKey) + if (block && block.isVoid) { + document = document.removeDescendant(block) + state = state.merge({ document }) + return state + } + + const inline = document.getClosestInline(startKey) + if (inline && inline.isVoid) { + document = document.removeDescendant(inline) + state = state.merge({ document }) + return state + } + + // When at end of a text node, merge forwards into the next text node. + const startNode = document.getDescendant(startKey) + if (range.isAtEndOf(startNode)) { + const next = document.getNextText(startNode) + range = range.extendToStartOf(next) + range = range.normalize(document) + return deleteAtRange(state, range) + } + + // Otherwise, remove `n` characters ahead of the cursor. + range = range.extendForward(n) + range = range.normalize(document) + return deleteAtRange(state, range) +} + +/** + * Insert a `block` node at `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Block or String or Object} block + * @return {State} + */ + +export function insertBlockAtRange(state, range, block) { + let { document } = state + + // Normalize the block argument. + block = normalizeBlock(block) + + // If expanded, delete the range first. + if (range.isExpanded) { + state = deleteAtRange(state, range) + document = state.document + range = range.collapseToStart() + } + + const { startKey, startOffset } = range + let startBlock = document.getClosestBlock(startKey) + let parent = document.getParent(startBlock) + let nodes = Block.createList([block]) + const isParent = parent == document + + // If the start block is void, insert after it. + if (startBlock.isVoid) { + parent = parent.insertChildrenAfter(startBlock, nodes) + } + + // If the block is empty, replace it. + else if (startBlock.isEmpty) { + parent = parent.insertChildrenAfter(startBlock, nodes) + parent = parent.removeDescendant(startBlock) + } + + // If the range is at the start of the block, insert before. + else if (range.isAtStartOf(startBlock)) { + parent = parent.insertChildrenBefore(startBlock, nodes) + } + + // If the range is at the end of the block, insert after. + else if (range.isAtEndOf(startBlock)) { + parent = parent.insertChildrenAfter(startBlock, nodes) + } + + // Otherwise, split the block and insert between. + else { + state = splitBlockAtRange(state, range) + document = state.document + parent = document.getParent(startBlock) + startBlock = document.getClosestBlock(startKey) + nodes = parent.nodes.takeUntil(n => n == startBlock) + .push(startBlock) + .push(block) + .concat(parent.nodes.skipUntil(n => n == startBlock).rest()) + parent = parent.merge({ nodes }) + } + + // Update the document. + document = isParent + ? parent + : document.updateDescendant(parent) + + // Normalize the document. + document = document.normalize() + + // Return the updated state. + state = state.merge({ document }) + return state +} + +/** + * Insert a `fragment` at a `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Document} fragment + * @return {State} + */ + +export function insertFragmentAtRange(state, range, fragment) { + let { document } = state + + // Ensure that the selection is normalized. + range = range.normalize(document) + + // If the range is expanded, delete first. + if (range.isExpanded) { + state = deleteAtRange(state, range) + document = state.document + range = range.collapseToStart() + } + + // If the fragment is empty, do nothing. + if (!fragment.length) return state + + // Make sure each node in the fragment has a unique key. + fragment = fragment.mapDescendants(child => child.set('key', uid())) + + // Split the inlines if need be. + if (!document.isInlineSplitAtRange(range)) { + state = splitInlineAtRange(state, range) + document = state.document + } + + // Determine the start and next children to insert into. + const { startKey, endKey } = range + let block = document.getClosestBlock(startKey) + let start = document.getDescendant(startKey) + let startChild + let nextChild + + if (range.isAtStartOf(document)) { + nextChild = document.getClosestBlock(document.getTexts().first()) + } + + if (range.isAtStartOf(block)) { + nextChild = block.getHighestChild(block.getTexts().first()) + } + + else if (range.isAtStartOf(start)) { + startChild = block.getHighestChild(block.getPreviousText(start)) + nextChild = block.getNextSibling(startChild) + } + + else { + startChild = block.getHighestChild(start) + nextChild = block.getNextSibling(startChild) + } + + // Get the first and last block of the fragment. + const blocks = fragment.getBlocks() + const firstBlock = blocks.first() + let lastBlock = blocks.last() + + // If the block is empty, merge in the first block's type and data. + if (block.length == 0) { + block = block.merge({ + type: firstBlock.type, + data: firstBlock.data + }) + } + + // Insert the first blocks nodes into the starting block. + if (startChild) { + block = block.insertChildrenAfter(startChild, firstBlock.nodes) + } else { + block = block.insertChildrenBefore(nextChild, firstBlock.nodes) + } + + document = document.updateDescendant(block) + + // If there are no other siblings, that's it. + if (firstBlock == lastBlock) { + document = document.normalize() + state = state.merge({ document }) + return state + } + + // Otherwise, remove the fragment's first block's highest solo parent... + let highestParent = fragment.getHighestOnlyChildParent(firstBlock) + fragment = fragment.removeDescendant(highestParent || firstBlock) + + // Then, add the inlines after the cursor from the current block to the + // start of the last block in the fragment. + if (nextChild) { + lastBlock = lastBlock.concatChildren(block.getChildrenAfterIncluding(nextChild)) + fragment = fragment.updateDescendant(lastBlock) + + block = block.removeChildrenAfterIncluding(nextChild) + document = document.updateDescendant(block) + } + + // Finally, add the fragment's children after the block. + document = document.insertChildrenAfter(block, fragment.nodes) + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Insert an `inline` node at `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Inline or String or Object} inline + * @return {State} + */ + +export function insertInlineAtRange(state, range, inline) { + let { document } = state + + // Normalize the inline argument. + inline = normalizeInline(inline) + + // If expanded, delete the range first. + if (range.isExpanded) { + state = deleteAtRange(state, range) + document = state.document + range = range.collapseToStart() + } + + const { startKey, endKey, startOffset, endOffset } = range + + // If the range is inside a void, abort. + const startBlock = document.getClosestBlock(startKey) + if (startBlock && startBlock.isVoid) return state + + const startInline = document.getClosestInline(startKey) + if (startInline && startInline.isVoid) return state + + // Split the text nodes at the cursor. + state = splitTextAtRange(state, range) + document = state.document + + // Insert the inline between the split text nodes. + const startText = document.getDescendant(startKey) + let parent = document.getParent(startKey) + const nodes = parent.nodes.takeUntil(n => n == startText) + .push(startText) + .push(inline) + .concat(parent.nodes.skipUntil(n => n == startText).rest()) + + parent = parent.merge({ nodes }) + document = document.updateDescendant(parent) + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Insert text `string` at a `range`, with optional `marks`. + * + * @param {State} state + * @param {Selection} range + * @param {String} string + * @param {Set} marks (optional) + * @return {State} + */ + +export function insertTextAtRange(state, range, string, marks) { + let { document } = state + const { startKey, startOffset } = range + const isVoid = document.hasVoidParent(startKey) + + // If inside a void node, do nothing. + if (isVoid) return state + + // Is the range is expanded, delete it first. + if (range.isExpanded) { + state = deleteAtRange(state, range) + document = state.document + range = range.collapseToStart() + } + + // Insert text at the range's offset. + let text = document.getDescendant(startKey) + text = text.insertText(startOffset, string, marks) + document = document.updateDescendant(text) + + // Return the updated selection. + state = state.merge({ document }) + return state +} + +/** + * Remove an existing `mark` to the characters at `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Mark or String} mark (optional) + * @return {State} + */ + +export function removeMarkAtRange(state, range, mark) { + mark = normalizeMark(mark) + let { document } = state + + // When the range is collapsed, do nothing. + if (range.isCollapsed) return state + + // Otherwise, find each of the text nodes within the range. + let texts = document.getTextsAtRange(range) + + // Apply the mark to each of the text nodes's matching characters. + texts = texts.map((text) => { + let characters = text.characters.map((char, i) => { + if (!isInRange(i, text, range)) return char + let { marks } = char + marks = mark + ? marks.remove(mark) + : marks.clear() + return char.merge({ marks }) + }) + + return text.merge({ characters }) + }) + + // Update each of the text nodes. + texts.forEach((text) => { + document = document.updateDescendant(text) + }) + + state = state.merge({ document }) + return state +} + +/** + * Set the `properties` of block nodes in a `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Object or String} properties + * @return {State} + */ + +export function setBlockAtRange(state, range, properties = {}) { + properties = normalizeProperties(properties) + let { document } = state + const blocks = document.getBlocksAtRange(range) + + blocks.forEach((block) => { + block = block.merge(properties) + document = document.updateDescendant(block) + }) + + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Set the `properties` of inline nodes in a `range`. + * + * @param {State} state + * @param {Selection} range + * @param {Object or String} properties + * @return {State} + */ + +export function setInlineAtRange(state, range, properties = {}) { + properties = normalizeProperties(properties) + let { document } = state + const inlines = document.getInlinesAtRange(range) + + inlines.forEach((inline) => { + inline = inline.merge(properties) + document = document.updateDescendant(inline) + }) + + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Split the block nodes at a `range`, to optional `depth`. + * + * @param {State} state + * @param {Selection} range + * @param {Number} depth (optional) + * @return {State} + */ + +export function splitBlockAtRange(state, range, depth = 1) { + let { document } = state + + // If the range is expanded, remove it first. + if (range.isExpanded) { + state = deleteAtRange(state, range) + document = state.document + range = range.collapseToStart() + } + + // Split the inline nodes at the range. + state = splitInlineAtRange(state, range) + document = state.document + + // Find the highest inline elements that were split. + const { startKey } = range + const firstText = document.getDescendant(startKey) + const secondText = document.getNextText(startKey) + let firstChild = document.getFurthestInline(firstText) || firstText + let secondChild = document.getFurthestInline(secondText) || secondText + let parent = document.getClosestBlock(firstChild) + let firstChildren + let secondChildren + let d = 0 + + // While the parent is a block, split the block nodes. + while (parent && d < depth) { + firstChildren = parent.nodes.takeUntil(n => n == firstChild).push(firstChild) + secondChildren = parent.nodes.skipUntil(n => n == secondChild) + firstChild = parent.merge({ nodes: firstChildren }) + secondChild = Block.create({ + nodes: secondChildren, + type: parent.type, + data: parent.data + }) + + // Add the new children. + const grandparent = document.getParent(parent) + const nodes = grandparent.nodes + .takeUntil(n => n.key == firstChild.key) + .push(firstChild) + .push(secondChild) + .concat(grandparent.nodes.skipUntil(n => n.key == firstChild.key).rest()) + + // Update the grandparent. + document = grandparent == document + ? document.merge({ nodes }) + : document.updateDescendant(grandparent.merge({ nodes })) + + d++ + parent = document.getClosestBlock(firstChild) + } + + state = state.merge({ document }) + return state +} + +/** + * Split the inline nodes at a `range`, to optional `depth`. + * + * @param {State} state + * @param {Selection} range + * @param {Number} depth (optiona) + * @return {State} + */ + +export function splitInlineAtRange(state, range, depth = Infinity) { + let { document } = state + + // If the range is expanded, remove it first. + if (range.isExpanded) { + state = deleteAtRange(state, range) + document = state.document + range = range.collapseToStart() + } + + // First split the text nodes. + state = splitTextAtRange(state, range) + document = state.document + + // Find the children that were split. + const { startKey } = range + let firstChild = document.getDescendant(startKey) + let secondChild = document.getNextText(firstChild) + let parent = document.getClosestInline(firstChild) + let d = 0 + + // While the parent is an inline parent, split the inline nodes. + while (parent && d < depth) { + firstChild = parent.merge({ nodes: Inline.createList([firstChild]) }) + secondChild = Inline.create({ + nodes: [secondChild], + type: parent.type, + data: parent.data + }) + + // Split the children. + const grandparent = document.getParent(parent) + const nodes = grandparent.nodes + .takeUntil(n => n.key == firstChild.key) + .push(firstChild) + .push(secondChild) + .concat(grandparent.nodes.skipUntil(n => n.key == firstChild.key).rest()) + + // Update the grandparent. + document = grandparent == document + ? document.merge({ nodes }) + : document.updateDescendant(grandparent.merge({ nodes })) + + d++ + parent = document.getClosestInline(firstChild) + } + + state = state.merge({ document }) + return state +} + +/** + * Split the text nodes at a `range`. + * + * @param {State} state + * @param {Selection} range + * @return {State} + */ + +export function splitTextAtRange(state, range) { + let { document } = state + + // If the range is expanded, remove it first. + if (range.isExpanded) { + state = deleteAtRange(state, range) + document = state.document + range = range.collapseToStart() + } + + // Split the text node's characters. + const { startKey, startOffset } = range + const text = document.getDescendant(startKey) + const { characters } = text + const firstChars = characters.take(startOffset) + const secondChars = characters.skip(startOffset) + let firstChild = text.merge({ characters: firstChars }) + let secondChild = Text.create({ characters: secondChars }) + + // Split the text nodes. + let parent = document.getParent(text) + const nodes = parent.nodes + .takeUntil(c => c.key == firstChild.key) + .push(firstChild) + .push(secondChild) + .concat(parent.nodes.skipUntil(n => n.key == firstChild.key).rest()) + + // Update the nodes. + parent = parent.merge({ nodes }) + document = document.updateDescendant(parent) + state = state.merge({ document }) + return state +} + +/** + * Add or remove a `mark` from the characters at `range`, depending on whether + * it's already there. + * + * @param {State} state + * @param {Selection} range + * @param {Mark or String} mark (optional) + * @return {State} + */ + +export function toggleMarkAtRange(state, range, mark) { + mark = normalizeMark(mark) + let { document } = state + + // When the range is collapsed, do nothing. + if (range.isCollapsed) return state + + // Check if the mark exists in the range already. + const marks = document.getMarksAtRange(range) + const exists = marks.some(m => m.equals(mark)) + + return exists + ? removeMarkAtRange(state, range, mark) + : addMarkAtRange(state, range, mark) +} + +/** + * Unwrap all of the block nodes in a `range` from a block with `properties`. + * + * @param {State} state + * @param {Selection} range + * @param {String or Object} properties + * @return {State} + */ + +export function unwrapBlockAtRange(state, range, properties) { + properties = normalizeProperties(properties) + let { document } = state + + // Get the deepest blocks in the range. + const blocks = document.getBlocksAtRange(range) + + // Get the matching wrapper blocks. + const wrappers = blocks.reduce((memo, text) => { + const match = document.getClosest(text, (parent) => { + if (parent.kind != 'block') return false + if (properties.type && parent.type != properties.type) return false + if (properties.data && !parent.data.isSuperset(properties.data)) return false + return true + }) + + if (match) memo = memo.add(match) + return memo + }, new Set()) + + // For each of the wrapper blocks... + wrappers.forEach((wrapper) => { + const first = wrapper.nodes.first() + const last = wrapper.nodes.last() + const parent = document.getParent(wrapper) + + // Get the wrapped direct children. + const children = wrapper.nodes.filter((child) => { + return blocks.some(block => child == block || child.hasDescendant(block)) + }) + + // Determine what the new nodes should be... + const firstMatch = children.first() + const lastMatch = children.last() + let nodes + + // If the first and last both match, remove the wrapper completely. + if (first == firstMatch && last == lastMatch) { + nodes = parent.nodes.takeUntil(n => n == wrapper) + .concat(wrapper.nodes) + .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) + } + + // If only the last child matches, move the last nodes. + else if (last == lastMatch) { + const remain = wrapper.nodes.takeUntil(n => n == firstMatch) + const updated = wrapper.merge({ nodes: remain }) + nodes = parent.nodes.takeUntil(n => n == wrapper) + .push(updated) + .concat(children) + .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) + } + + // If only the first child matches, move the first ones. + else if (first == firstMatch) { + const remain = wrapper.nodes.skipUntil(n => n == lastMatch).rest() + const updated = wrapper.merge({ nodes: remain }) + nodes = parent.nodes.takeUntil(n => n == wrapper) + .concat(children) + .push(updated) + .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) + } + + // Otherwise, move the middle ones. + else { + const firsts = wrapper.nodes.takeUntil(n => n == firstMatch) + const lasts = wrapper.nodes.skipUntil(n => n == lastMatch).rest() + const updatedFirst = wrapper.merge({ nodes: firsts }) + const updatedLast = wrapper.merge({ nodes: lasts }) + nodes = parent.nodes.takeUntil(n => n == wrapper) + .push(updatedFirst) + .concat(children) + .push(updatedLast) + .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) + } + + document = parent == document + ? document.merge({ nodes }) + : document.updateDescendant(parent.merge({ nodes })) + }) + + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Unwrap the inline nodes in a `range` from an inline with `properties`. + * + * @param {State} state + * @param {Selection} range + * @param {String or Object} properties + * @return {State} + */ + +export function unwrapInlineAtRange(state, range, properties) { + properties = normalizeProperties(properties) + let { document } = state + let blocks = document.getInlinesAtRange(range) + + // Find the closest matching inline wrappers of each text node. + const texts = document.getTexts() + const wrappers = texts.reduce((memo, text) => { + const match = document.getClosest(text, (parent) => { + if (parent.kind != 'inline') return false + if (properties.type && parent.type != properties.type) return false + if (properties.data && !parent.data.isSuperset(properties.data)) return false + return true + }) + + if (match) memo = memo.add(match) + return memo + }, new Set()) + + // Replace each of the wrappers with their child nodes. + wrappers.forEach((wrapper) => { + const parent = document.getParent(wrapper) + + // Replace the wrapper in the parent's nodes with the block. + const nodes = parent.nodes.takeUntil(n => n == wrapper) + .concat(wrapper.nodes) + .concat(parent.nodes.skipUntil(n => n == wrapper).rest()) + + // Update the parent. + document = parent == document + ? document.merge({ nodes }) + : document.updateDescendant(parent.merge({ nodes })) + }) + + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Wrap all of the blocks in a `range` in a new block with `properties`. + * + * @param {State} state + * @param {Selection} range + * @param {String or Object} properties + * @return {State} + */ + +export function wrapBlockAtRange(state, range, properties) { + properties = normalizeProperties(properties) + let { document } = state + + // Get the block nodes, sorted by depth. + const blocks = document.getBlocksAtRange(range) + const sorted = blocks.sort((a, b) => { + const da = document.getDepth(a) + const db = document.getDepth(b) + if (da == db) return 0 + else if (da > db) return -1 + else return 1 + }) + + // Get the lowest common siblings, relative to the highest block. + const highest = sorted.first() + const depth = document.getDepth(highest) + const siblings = blocks.reduce((memo, block) => { + const sibling = document.getDepth(block) == depth + ? block + : document.getClosest(block, (p) => document.getDepth(p) == depth) + memo = memo.push(sibling) + return memo + }, Block.createList()) + + // Wrap the siblings in a new block. + const wrapper = Block.create({ + nodes: siblings, + type: properties.type, + data: properties.data + }) + + // Replace the siblings with the wrapper. + const first = siblings.first() + const last = siblings.last() + const parent = document.getParent(highest) + const nodes = parent.nodes + .takeUntil(n => n == first) + .push(wrapper) + .concat(parent.nodes.skipUntil(n => n == last).rest()) + + // Update the parent. + document = parent == document + ? document.merge({ nodes }) + : document.updateDescendant(parent.merge({ nodes })) + + state = state.merge({ document }) + return state +} + +/** + * Wrap the text and inlines in a `range` in a new inline with `properties`. + * + * @param {State} state + * @param {Selection} range + * @param {String or Object} properties + * @return {State} + */ + +export function wrapInlineAtRange(state, range, properties) { + properties = normalizeProperties(properties) + let { document } = state + + // If collapsed, there's nothing to wrap. + if (range.isCollapsed) return state + + // Split at the start of the range. + const start = range.collapseToStart() + state = splitInlineAtRange(state, start) + document = state.document + + // Determine the new end of the range, and split there. + const { startKey, startOffset, endKey, endOffset } = range + const firstNode = document.getDescendant(startKey) + const nextNode = document.getNextText(startKey) + const end = startKey != endKey + ? range.collapseToEnd() + : Selection.create({ + anchorKey: nextNode.key, + anchorOffset: endOffset - startOffset, + focusKey: nextNode.key, + focusOffset: endOffset - startOffset + }) + + state = splitInlineAtRange(state, end) + document = state.document + + // Calculate the new range to wrap around. + const endNode = document.getDescendant(end.anchorKey) + range = Selection.create({ + anchorKey: nextNode.key, + anchorOffset: 0, + focusKey: endNode.key, + focusOffset: endNode.length + }) + + // Get the furthest inline nodes in the range. + const texts = document.getTextsAtRange(range) + const children = texts.map(text => document.getFurthestInline(text) || text) + + // Iterate each of the child nodes, wrapping them. + children.forEach((child) => { + const wrapper = Inline.create({ + nodes: [child], + type: properties.type, + data: properties.data + }) + + // Replace the child in it's parent with the wrapper. + const parent = document.getParent(child) + const nodes = parent.nodes.takeUntil(n => n == child) + .push(wrapper) + .concat(parent.nodes.skipUntil(n => n == child).rest()) + + // Update the parent. + document = parent == document + ? document.merge({ nodes }) + : document.updateDescendant(parent.merge({ nodes })) + }) + + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Wrap the text in a `range` in a prefix/suffix. + * + * @param {State} state + * @param {Selection} range + * @param {String} prefix + * @param {String} suffix + * @return {State} + */ + +export function wrapTextAtRange(state, range, prefix, suffix = prefix) { + // Insert text at the starting edge. + const { startKey, endKey } = range + const start = range.collapseToStart() + state = insertTextAtRange(state, start, prefix) + + // Determine the new ending edge, and insert text there. + let end = range.collapseToEnd() + if (startKey == endKey) end = end.moveForward(prefix.length) + state = insertTextAtRange(state, end, suffix) + return state +} diff --git a/lib/transforms/by-current-keys.js b/lib/transforms/by-current-keys.js new file mode 100644 index 000000000..e69de29bb diff --git a/lib/transforms/by-key.js b/lib/transforms/by-key.js new file mode 100644 index 000000000..92f93154a --- /dev/null +++ b/lib/transforms/by-key.js @@ -0,0 +1,37 @@ + +import normalizeProperties from '../utils/normalize-node-or-mark-properties' + +/** + * Remove a node by `key`. + * + * @param {State} state + * @param {String} key + * @return {State} state + */ + +export function removeNodeByKey(state, key) { + let { document } = state + document = document.removeDescendant(key) + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Set `properties` on a node by `key`. + * + * @param {State} state + * @param {String} key + * @param {Object or String} properties + * @return {State} state + */ + +export function setNodeByKey(state, key, properties) { + properties = normalizeProperties(properties) + let { document } = state + let descendant = document.assertDescendant(key) + descendant = descendant.merge(properties) + document = document.updateDescendant(descendant) + state = state.merge({ document }) + return state +} diff --git a/lib/transforms/index.js b/lib/transforms/index.js new file mode 100644 index 000000000..18e6af703 --- /dev/null +++ b/lib/transforms/index.js @@ -0,0 +1,188 @@ + +/** + * At range. + */ + +import { + deleteAtRange, + deleteBackwardAtRange, + deleteForwardAtRange, + insertBlockAtRange, + insertFragmentAtRange, + insertInlineAtRange, + insertTextAtRange, + addMarkAtRange, + setBlockAtRange, + setInlineAtRange, + splitBlockAtRange, + splitInlineAtRange, + removeMarkAtRange, + toggleMarkAtRange, + unwrapBlockAtRange, + unwrapInlineAtRange, + wrapBlockAtRange, + wrapInlineAtRange, + wrapTextAtRange, +} from './at-range' + +/** + * At current range. + */ + +import { + _delete, + deleteBackward, + deleteForward, + insertBlock, + insertFragment, + insertInline, + insertText, + addMark, + setBlock, + setInline, + splitBlock, + splitInline, + removeMark, + toggleMark, + unwrapBlock, + unwrapInline, + wrapBlock, + wrapInline, + wrapText, +} from './at-current-range' + +/** + * By key. + */ + +import { + removeNodeByKey, + setNodeByKey, +} from './by-key' + +/** + * On selection. + */ + +import { + blur, + collapseToAnchor, + collapseToEnd, + collapseToEndOf, + collapseToEndOfNextBlock, + collapseToEndOfNextText, + collapseToEndOfPreviousBlock, + collapseToEndOfPreviousText, + collapseToFocus, + collapseToStart, + collapseToStartOf, + collapseToStartOfNextBlock, + collapseToStartOfNextText, + collapseToStartOfPreviousBlock, + collapseToStartOfPreviousText, + extendBackward, + extendForward, + extendToEndOf, + extendToStartOf, + focus, + moveBackward, + moveForward, + moveTo, + moveToOffsets, + moveToRangeOf, +} from './on-selection' + +/** + * Export. + * + * @type {Object} + */ + +export default { + + /** + * At range. + */ + + deleteAtRange, + deleteBackwardAtRange, + deleteForwardAtRange, + insertBlockAtRange, + insertFragmentAtRange, + insertInlineAtRange, + insertTextAtRange, + addMarkAtRange, + setBlockAtRange, + setInlineAtRange, + splitBlockAtRange, + splitInlineAtRange, + removeMarkAtRange, + toggleMarkAtRange, + unwrapBlockAtRange, + unwrapInlineAtRange, + wrapBlockAtRange, + wrapInlineAtRange, + wrapTextAtRange, + + /** + * At current range. + */ + + delete: _delete, + deleteBackward, + deleteForward, + insertBlock, + insertFragment, + insertInline, + insertText, + addMark, + setBlock, + setInline, + splitBlock, + splitInline, + removeMark, + toggleMark, + unwrapBlock, + unwrapInline, + wrapBlock, + wrapInline, + wrapText, + + /** + * By key. + */ + + removeNodeByKey, + setNodeByKey, + + /** + * On selection. + */ + + blur, + collapseToAnchor, + collapseToEnd, + collapseToEndOf, + collapseToEndOfNextBlock, + collapseToEndOfNextText, + collapseToEndOfPreviousBlock, + collapseToEndOfPreviousText, + collapseToFocus, + collapseToStart, + collapseToStartOf, + collapseToStartOfNextBlock, + collapseToStartOfNextText, + collapseToStartOfPreviousBlock, + collapseToStartOfPreviousText, + extendBackward, + extendForward, + extendToEndOf, + extendToStartOf, + focus, + moveBackward, + moveForward, + moveTo, + moveToOffsets, + moveToRangeOf, + +} diff --git a/lib/transforms/on-selection.js b/lib/transforms/on-selection.js new file mode 100644 index 000000000..0fa65869a --- /dev/null +++ b/lib/transforms/on-selection.js @@ -0,0 +1,211 @@ + +import Selection from '../models/selection' + +/** + * Move the selection to the end of the previous block. + * + * @param {State} state + * @return {State} + */ + +export function collapseToEndOfPreviousBlock(state) { + let { document, selection } = state + let blocks = document.getBlocksAtRange(selection) + let block = blocks.first() + if (!block) return state + + let previous = document.getPreviousBlock(block) + if (!previous) return state + + selection = selection.collapseToEndOf(previous) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to the start of the previous block. + * + * @param {State} state + * @return {State} + */ + +export function collapseToStartOfPreviousBlock(state) { + let { document, selection } = state + let blocks = document.getBlocksAtRange(selection) + let block = blocks.first() + if (!block) return state + + let previous = document.getPreviousBlock(block) + if (!previous) return state + + selection = selection.collapseToStartOf(previous) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to the start of the next block. + * + * @param {State} state + * @return {State} + */ + +export function collapseToStartOfNextBlock(state) { + let { document, selection } = state + let blocks = document.getBlocksAtRange(selection) + let block = blocks.last() + if (!block) return state + + let next = document.getNextBlock(block) + if (!next) return state + + selection = selection.collapseToStartOf(next) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to the end of the next block. + * + * @param {State} state + * @return {State} + */ + +export function collapseToEndOfNextBlock(state) { + let { document, selection } = state + let blocks = document.getBlocksAtRange(selection) + let block = blocks.last() + if (!block) return state + + let next = document.getNextBlock(block) + if (!next) return state + + selection = selection.collapseToEndOf(next) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to the start of the previous text. + * + * @param {State} state + * @return {State} + */ + +export function collapseToStartOfPreviousText(state) { + let { document, selection } = state + let texts = document.getTextsAtRange(selection) + let text = texts.first() + if (!text) return state + + let previous = document.getPreviousText(text) + if (!previous) return state + + selection = selection.collapseToStartOf(previous) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to the end of the previous text. + * + * @param {State} state + * @return {State} + */ + +export function collapseToEndOfPreviousText(state) { + let { document, selection } = state + let texts = document.getTextsAtRange(selection) + let text = texts.first() + if (!text) return state + + let previous = document.getPreviousText(text) + if (!previous) return state + + selection = selection.collapseToEndOf(previous) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to the start of the next text. + * + * @param {State} state + * @return {State} + */ + +export function collapseToStartOfNextText(state) { + let { document, selection } = state + let texts = document.getTextsAtRange(selection) + let text = texts.last() + if (!text) return state + + let next = document.getNextText(text) + if (!next) return state + + selection = selection.collapseToStartOf(next) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to the end of the next text. + * + * @param {State} state + * @return {State} + */ + +export function collapseToEndOfNextText(state) { + let { document, selection } = state + let texts = document.getTextsAtRange(selection) + let text = texts.last() + if (!text) return state + + let next = document.getNextText(text) + if (!next) return state + + selection = selection.collapseToEndOf(next) + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} + +/** + * Move the selection to a specific anchor and focus point. + * + * @param {State} state + * @param {Object} properties + * @return {State} + */ + +export function moveTo(state, properties) { + let { document, selection } = state + + // Allow for passing a `Selection` object. + if (properties instanceof Selection) { + properties = { + anchorKey: properties.anchorKey, + anchorOffset: properties.anchorOffset, + focusKey: properties.focusKey, + focusOffset: properties.focusOffset, + isFocused: properties.isFocused + } + } + + // Pass in properties, and force `isBackward` to be re-resolved. + selection = selection.merge({ + ...properties, + isBackward: null + }) + + selection = selection.normalize(document) + state = state.merge({ selection }) + return state +} diff --git a/lib/transforms/options.js b/lib/transforms/options.js new file mode 100644 index 000000000..2ee2aef67 --- /dev/null +++ b/lib/transforms/options.js @@ -0,0 +1,13 @@ + +/** + * Mark the transforms + */ + + +/** + * Mark the transform as not being "distinct", in that it by itself should not + * create a new save boundary. + * + * @param {State} state + * @param + */ diff --git a/lib/utils/is-in-range.js b/lib/utils/is-in-range.js new file mode 100644 index 000000000..68aff21a7 --- /dev/null +++ b/lib/utils/is-in-range.js @@ -0,0 +1,32 @@ + +/** + * Check if an `index` of a `text` node is in a `range`. + * + * @param {Number} index + * @param {Text} text + * @param {Selection} range + * @return {Set} characters + */ + +function isInRange(index, text, range) { + const { startKey, startOffset, endKey, endOffset } = range + let matcher + + if (text.key == startKey && text.key == endKey) { + return startOffset <= index && index < endOffset + } else if (text.key == startKey) { + return startOffset <= index + } else if (text.key == endKey) { + return index < endOffset + } else { + return true + } +} + +/** + * Export. + * + * @type {Function} + */ + +export default isInRange diff --git a/lib/utils/normalize-block.js b/lib/utils/normalize-block.js new file mode 100644 index 000000000..28a6ed820 --- /dev/null +++ b/lib/utils/normalize-block.js @@ -0,0 +1,35 @@ + +import Block from '../models/block' +import normalizeProperties from './normalize-node-or-mark-properties' +import typeOf from 'type-of' + +/** + * Normalize a `block` argument, which can be a string or plain object too. + * + * @param {Block or String or Object} block + * @return {Block} + */ + +function normalizeBlock(block) { + if (block instanceof Block) return block + + const type = typeOf(block) + + switch (type) { + case 'string': + case 'object': { + return Block.create(normalizeProperties(block)) + } + default: { + throw new Error(`A \`block\` argument must be a block, an object or a string, but you passed: "${type}".`) + } + } +} + +/** + * Export. + * + * @type {Function} + */ + +export default normalizeBlock diff --git a/lib/utils/normalize-inline.js b/lib/utils/normalize-inline.js new file mode 100644 index 000000000..2ab963790 --- /dev/null +++ b/lib/utils/normalize-inline.js @@ -0,0 +1,35 @@ + +import Inline from '../models/inline' +import normalizeProperties from './normalize-node-or-mark-properties' +import typeOf from 'type-of' + +/** + * Normalize an `inline` argument, which can be a string or plain object too. + * + * @param {Inline or String or Object} inline + * @return {Inline} + */ + +function normalizeInline(inline) { + if (inline instanceof Inline) return inline + + const type = typeOf(inline) + + switch (type) { + case 'string': + case 'object': { + return Inline.create(normalizeProperties(inline)) + } + default: { + throw new Error(`An \`inline\` argument must be an inline, an object or a string, but you passed: "${type}".`) + } + } +} + +/** + * Export. + * + * @type {Function} + */ + +export default normalizeInline diff --git a/lib/utils/normalize-mark.js b/lib/utils/normalize-mark.js new file mode 100644 index 000000000..292204585 --- /dev/null +++ b/lib/utils/normalize-mark.js @@ -0,0 +1,35 @@ + +import Mark from '../models/mark' +import typeOf from 'type-of' +import normalizeProperties from './normalize-node-or-mark-properties' + +/** + * Normalize a `mark` argument, which can be a string or plain object too. + * + * @param {Mark or String or Object} mark + * @return {Mark} + */ + +function normalizeMark(mark) { + if (mark instanceof Mark) return mark + + const type = typeOf(mark) + + switch (type) { + case 'string': + case 'object': { + return Mark.create(normalizeProperties(mark)) + } + default: { + throw new Error(`A \`mark\` argument must be a mark, an object or a string, but you passed: "${type}".`) + } + } +} + +/** + * Export. + * + * @type {Function} + */ + +export default normalizeMark diff --git a/lib/utils/normalize-node-or-mark-properties.js b/lib/utils/normalize-node-or-mark-properties.js new file mode 100644 index 000000000..e0d618e0c --- /dev/null +++ b/lib/utils/normalize-node-or-mark-properties.js @@ -0,0 +1,47 @@ + +import Data from '../models/data' +import typeOf from 'type-of' + +/** + * Normalize the `properties` of a node or mark, which can be either a type + * string or a dictionary of properties. If it's a dictionary, `data` is + * optional and shouldn't be set if null or undefined. + * + * @param {String or Object} properties + * @return {Object} + */ + +function normalizeNodeOrMarkProperties(properties = {}) { + const ret = {} + const type = typeOf(properties) + + switch (type) { + case 'string': { + ret.type = properties + break + } + case 'object': { + for (const key in properties) { + if (key == 'data') { + if (properties[key] != null) ret[key] = Data.create(properties[key]) + } else { + ret[key] = properties[key] + } + } + break + } + default: { + throw new Error(`A \`properties\` argument must be an object or a string, but you passed: "${type}".`) + } + } + + return ret +} + +/** + * Export. + * + * @type {Function} + */ + +export default normalizeNodeOrMarkProperties