diff --git a/examples/links/index.js b/examples/links/index.js index 8249dde4e..670f3373b 100644 --- a/examples/links/index.js +++ b/examples/links/index.js @@ -60,7 +60,6 @@ class Links extends React.Component { onChange = (state) => { this.setState({ state }) - console.log(state.document.toJS()) } /** diff --git a/lib/models/transform.js b/lib/models/transform.js index ba662afd3..327cbf8c2 100644 --- a/lib/models/transform.js +++ b/lib/models/transform.js @@ -1,7 +1,5 @@ -import Operations from '../transforms/operations' import Transforms from '../transforms' -import { List, Record } from 'immutable' /** * Transform. @@ -18,10 +16,13 @@ class Transform { */ constructor(properties) { - const { state } = properties - this.initialState = state + const { state, operations = [] } = properties this.state = state this.operations = [] + + operations.forEach(op => { + this.applyOperation(op) + }) } /** @@ -34,26 +35,6 @@ class Transform { return 'transform' } - /** - * Add an `operation` to the transform that resulted in `state`. - * - * @param {Object} operation - * @return {Transform} - */ - - operate(operation) { - const { type } = operation - const fn = Operations[type] - - if (!fn) { - throw new Error(`Unknown operation type: "${type}".`) - } - - this.state = fn(this.state, operation) - this.operations.push(operation) - return this - } - /** * Apply the transform and return the new state. * @@ -64,38 +45,38 @@ class Transform { */ apply(options = {}) { - let { initialState, state, operations } = this - let { history, selection } = state - let { marks } = selection + let { state, operations } = this + let { history } = state let { undos, redos } = history + // If there are no operations, abort early. + if (!operations.length) return state + + // The `isNative` flag allows for natively-handled changes to skip + // rerendering the editor for improved performance. + const isNative = !!options.isNative + // Determine whether we need to create a new snapshot. const shouldSnapshot = options.snapshot == null ? this.shouldSnapshot() : options.snapshot - // If we should, save a snapshot into the history before transforming. + // Either create a new snapshot, or push the operations into the previous. if (shouldSnapshot) { - const snapshot = this.snapshot() + const snapshot = { operations } undos = undos.push(snapshot) - if (undos.size > 100) undos = undos.take(100) - redos = redos.clear() - history = history.merge({ undos, redos }) - state = state.merge({ history }) + } else { + const snapshot = undos.peek() + snapshot.operations = snapshot.operations.concat(operations) } - // Check whether to remove the cursor marks. - // if (options.snapshot !== false && initialState.selection.marks) { - // selection = selection.merge({ marks: null }) - // state = state.merge({ selection }) - // } - - // Apply the "isNative" flag, which is used to allow for natively-handled - // content changes to skip rerendering the editor for performance. - state = state.merge({ - isNative: !!options.isNative - }) + // Clear the redo stack and constrain the undos stack. + if (undos.size > 100) undos = undos.take(100) + redos = redos.clear() + // Update the state. + history = history.merge({ undos, redos }) + state = state.merge({ history, isNative }) return state } @@ -114,69 +95,32 @@ class Transform { // If there isn't a previous state, snapshot. if (!previous) return true - // If the only operations applied are selection operations, don't snapshot. - const onlySelections = operations.every(op => op.type == 'set_selection') - if (onlySelections) return false + const types = operations.map(op => op.type) + const prevTypes = previous.operations.map(op => op.type) + const edits = types.filter(type => type != 'set_selection') + const prevEdits = prevTypes.filter(type => type != 'set_selection') - // If the current operations aren't one of the "combinable" types, snapshot. - const onlyInsert = operations.every(op => op.type == 'insert_text') - const onlyRemove = operations.every(op => op.type == 'remove_text') - const onlyPrevInsert = previous.operations.every(op => op.type == 'insert_text') - const onlyPrevRemove = previous.operations.every(op => op.type == 'remove_text') - if (onlyInsert && onlyPrevInsert) return false - if (onlyRemove && onlyPrevRemove) return false + const onlySelections = types.every(type => type == 'set_selection') + const onlyInsert = edits.every(type => type == 'insert_text') + const onlyRemove = edits.every(type => type == 'remove_text') + const prevOnlySelections = prevTypes.every(type => type == 'set_selection') + const prevOnlyInsert = prevEdits.every(type => type == 'insert_text') + const prevOnlyRemove = prevEdits.every(type => type == 'remove_text') + + // If the only operations applied are selection operations, or if the + // current operations are all text editing, don't snapshot. + if ( + (onlySelections) || + (!prevOnlySelections && onlyInsert && prevOnlyInsert) || + (!prevOnlySelections && onlyRemove && prevOnlyRemove) + ) { + return false + } // Otherwise, snapshot. return true } - /** - * Create a history-ready snapshot of the current state. - * - * @return {Object} - */ - - snapshot() { - let { initialState, operations } = this - let { document, selection } = initialState - return { document, selection, operations } - } - - /** - * Undo to the previous state in the history. - * - * @return {State} state - */ - - undo() { - let { state } = this - let { history } = state - let { undos, redos } = history - - // If there's no previous snapshot, return the current state. - let previous = undos.peek() - if (!previous) return state - - // Remove the previous snapshot from the undo stack. - undos = undos.pop() - - // Snapshot the current state, and move it into the redos stack. - let snapshot = this.snapshot() - redos = redos.push(snapshot) - - // Return the previous state, with the updated history. - let { document, selection } = previous - history = history.merge({ undos, redos }) - state = state.merge({ - document, - selection, - history, - isNative: false - }) - - return state - } - /** * Redo to the next state in the history. * @@ -192,21 +136,58 @@ class Transform { let next = redos.peek() if (!next) return state - // Remove the next history from the redo stack. + // Shift the next state into the undo stack. redos = redos.pop() + undos = undos.push(next) - // Snapshot the current state, and move it into the undos stack. - let snapshot = this.snapshot() - undos = undos.push(snapshot) + // Replay the next operations. + const { operations } = next + operations.forEach(op => { + this.applyOperation(op) + }) - // Return the next state, with the updated history. - let { document, selection } = next + // Update the state's history and force `isNative` to false. history = history.merge({ undos, redos }) - state = state.merge({ - document, - selection, + state = this.state.merge({ history, - isNative: false + isNative: false, + }) + + return state + } + + /** + * Undo the previous operations in the history. + * + * @return {State} state + */ + + undo() { + let { state } = this + let { history } = state + let { undos, redos } = history + + // If there's no previous snapshot, return the current state. + let previous = undos.peek() + if (!previous) return state + + // Shift the previous operations into the redo stack. + undos = undos.pop() + redos = redos.push(previous) + + // Replay the inverse of the previous operations. + const operations = previous.operations.slice().reverse() + operations.forEach(op => { + op.inverse.forEach(inv => { + this.applyOperation(inv) + }) + }) + + // Update the state's history and force `isNative` to false. + history = history.merge({ undos, redos }) + state = this.state.merge({ + history, + isNative: false, }) return state diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 019bd0ffd..ea393bfc6 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -568,7 +568,6 @@ function Plugin(options = {}) { return state .transform() .moveTo(selection) - .focus() .apply() } diff --git a/lib/transforms/apply-operation.js b/lib/transforms/apply-operation.js new file mode 100644 index 000000000..d5e37a315 --- /dev/null +++ b/lib/transforms/apply-operation.js @@ -0,0 +1,297 @@ + +import uid from '../utils/uid' + +/** + * Operations. + * + * @type {Object} + */ + +const OPERATIONS = { + // Text operations. + insert_text: insertText, + remove_text: removeText, + // Mark operations. + add_mark: addMark, + remove_mark: removeMark, + set_mark: setMark, + // Node operations. + insert_node: insertNode, + move_node: moveNode, + remove_node: removeNode, + set_node: setNode, + split_node: splitNode, + // Selection operations. + set_selection: setSelection, +} + +/** + * Apply an `operation` to the current state. + * + * @param {Transform} transform + * @param {Object} operation + * @return {Transform} + */ + +export function applyOperation(transform, operation) { + let { state, operations } = transform + const { type } = operation + const fn = OPERATIONS[type] + + if (!fn) { + throw new Error(`Unknown operation type: "${type}".`) + } + + transform.state = fn(state, operation) + transform.operations = operations.concat([operation]) + return transform +} + +/** + * Add mark to text at `offset` and `length` in node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function addMark(state, operation) { + const { path, offset, length, mark } = operation + let { document } = state + let node = document.assertPath(path) + node = node.addMark(offset, length, mark) + document = document.updateDescendant(node) + state = state.merge({ document }) + return state +} + +/** + * Insert a `node` at `index` in a node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function insertNode(state, operation) { + const { path, index, node } = operation + let { document } = state + let parent = document.assertPath(path) + const isParent = document == parent + const nodes = parent.nodes.splice(index, 0, node) + parent = parent.merge({ nodes }) + document = isParent ? parent : document.updateDescendant(parent) + state = state.merge({ document }) + return state +} + +/** + * Insert `text` at `offset` in node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function insertText(state, operation) { + const { path, offset, text, marks } = operation + let { document } = state + let node = document.assertPath(path) + node = node.insertText(offset, text, marks) + document = document.updateDescendant(node) + state = state.merge({ document }) + return state +} + +/** + * Move a node by `path` to a new parent by `path` and `index`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function moveNode(state, operation) { + const { path, newPath, newIndex } = operation + let { document } = state + const node = document.assertPath(path) + + let parent = document.getParent(node) + const isParent = document == parent + const index = parent.nodes.indexOf(node) + parent = parent.removeNode(index) + document = isParent ? parent : document.updateDescendant(parent) + + let target = document.assertPath(newPath) + const isTarget = document == target + target = target.insertNode(newIndex, node) + document = isTarget ? target : document.updateDescendant(target) + + state = state.merge({ document }) + return state +} + +/** + * Remove mark from text at `offset` and `length` in node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function removeMark(state, operation) { + const { path, offset, length, mark } = operation + let { document } = state + let node = document.assertPath(path) + node = node.removeMark(offset, length, mark) + document = document.updateDescendant(node) + state = state.merge({ document }) + return state +} + +/** + * Remove a node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function removeNode(state, operation) { + const { path } = operation + let { document } = state + const node = document.assertPath(path) + let parent = document.getParent(node) + const index = parent.nodes.indexOf(node) + const isParent = document == parent + parent = parent.removeNode(index) + document = isParent ? parent : document.updateDescendant(parent) + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Remove text at `offset` and `length` in node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function removeText(state, operation) { + const { path, offset, length } = operation + let { document } = state + let node = document.assertPath(path) + node = node.removeText(offset, length) + document = document.updateDescendant(node) + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Set `properties` on mark on text at `offset` and `length` in node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function setMark(state, operation) { + const { path, offset, length, mark, properties } = operation + let { document } = state + let node = document.assertPath(path) + node = node.updateMark(offset, length, mark, properties) + document = document.updateDescendant(node) + state = state.merge({ document }) + return state +} + +/** + * Set `properties` on a node by `path`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function setNode(state, operation) { + const { path, properties } = operation + let { document } = state + let node = document.assertPath(path) + node = node.merge(properties) + document = document.updateDescendant(node) + document = document.normalize() + state = state.merge({ document }) + return state +} + +/** + * Set `properties` on the selection. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function setSelection(state, operation) { + let { properties } = operation + let { selection } = state + selection = selection.merge(properties) + state = state.merge({ selection }) + return state +} + +/** + * Split a node by `path` at `offset`. + * + * @param {State} state + * @param {Object} operation + * @return {State} + */ + +function splitNode(state, operation) { + const { path, offset } = operation + let { document } = state + let node = document.assertPath(path) + let parent = document.getParent(node) + const isParent = document == parent + const index = parent.nodes.indexOf(node) + + let child = node + let one + let two + + if (node.kind != 'text') { + child = node.getTextAtOffset(offset) + } + + while (child && child != parent) { + if (child.kind == 'text') { + const i = node.kind == 'text' ? offset : offset - node.getOffset(child) + const { characters } = child + const oneChars = characters.take(i) + const twoChars = characters.skip(i) + one = child.merge({ characters: oneChars }) + two = child.merge({ characters: twoChars, key: uid() }) + } + + else { + const { nodes } = child + const oneNodes = nodes.takeUntil(n => n.key == one.key).push(one) + const twoNodes = nodes.skipUntil(n => n.key == one.key).rest().unshift(two) + one = child.merge({ nodes: oneNodes }) + two = child.merge({ nodes: twoNodes, key: uid() }) + } + + child = document.getParent(child) + } + + parent = parent.removeNode(index) + parent = parent.insertNode(index, two) + parent = parent.insertNode(index, one) + document = isParent ? parent : document.updateDescendant(parent) + state = state.merge({ document }) + return state +} diff --git a/lib/transforms/at-current-range.js b/lib/transforms/at-current-range.js index 1a02dfe3a..31eb0c7e7 100644 --- a/lib/transforms/at-current-range.js +++ b/lib/transforms/at-current-range.js @@ -22,13 +22,13 @@ export function addMark(transform, mark) { else if (selection.marks) { const marks = selection.marks.add(mark) const sel = selection.merge({ marks }) - return transform.setSelection(sel) + return transform.moveTo(sel) } else { const marks = document.getMarksAtRange(selection).add(mark) const sel = selection.merge({ marks }) - return transform.setSelection(sel) + return transform.moveTo(sel) } } @@ -81,7 +81,7 @@ export function _delete(transform) { return transform .deleteAtRange(selection) - .setSelection(after) + .moveTo(after) } /** @@ -155,7 +155,7 @@ export function deleteBackward(transform, n = 1) { return transform .deleteBackwardAtRange(selection, n) - .setSelection(after) + .moveTo(after) } /** @@ -214,7 +214,7 @@ export function deleteForward(transform, n = 1) { return transform .deleteForwardAtRange(selection, n) - .setSelection(after) + .moveTo(after) } /** @@ -237,7 +237,7 @@ export function insertBlock(transform, block) { const text = document.getTexts().find(n => !keys.includes(n.key)) const after = selection.collapseToEndOf(text) - return transform.setSelection(after) + return transform.moveTo(after) } /** @@ -287,7 +287,7 @@ export function insertFragment(transform, fragment) { .moveForward(lastText.length) } - return transform.setSelection(after) + return transform.moveTo(after) } /** @@ -319,7 +319,7 @@ export function insertInline(transform, inline) { after = selection.collapseToEndOf(text) } - return transform.setSelection(after) + return transform.moveTo(after) } /** @@ -354,7 +354,7 @@ export function insertText(transform, text, marks) { return transform .insertTextAtRange(selection, text, marks) - .setSelection(after) + .moveTo(after) } /** @@ -407,7 +407,7 @@ export function splitBlock(transform, depth = 1) { const nextNode = document.getNextText(startNode) const after = selection.collapseToStartOf(nextNode) - return transform.setSelection(after) + return transform.moveTo(after) } /** @@ -436,7 +436,7 @@ export function splitInline(transform, depth = Infinity) { after = selection.collapseToStartOf(nextNode) } - return transform.setSelection(after) + return transform.moveTo(after) } /** @@ -460,13 +460,13 @@ export function removeMark(transform, mark) { else if (selection.marks) { const marks = selection.marks.remove(mark) const sel = selection.merge({ marks }) - return transform.setSelection(sel) + return transform.moveTo(sel) } else { const marks = document.getMarksAtRange(selection).remove(mark) const sel = selection.merge({ marks }) - return transform.setSelection(sel) + return transform.moveTo(sel) } } @@ -582,7 +582,7 @@ export function wrapInline(transform, properties) { } after = after.normalize(document) - return transform.setSelection(after) + return transform.moveTo(after) } /** @@ -613,5 +613,5 @@ export function wrapText(transform, prefix, suffix = prefix) { return transform .wrapTextAtRange(selection, prefix, suffix) - .setSelection(after) + .moveTo(after) } diff --git a/lib/transforms/by-key.js b/lib/transforms/by-key.js index b9fa91d9a..2dd0f5572 100644 --- a/lib/transforms/by-key.js +++ b/lib/transforms/by-key.js @@ -15,29 +15,10 @@ import uid from '../utils/uid' export function addMarkByKey(transform, key, offset, length, mark) { mark = Normalize.mark(mark) - const { state } = transform const { document } = state const path = document.getPath(key) - - const inverse = { - type: 'remove_mark', - path, - offset, - length, - mark, - } - - const operation = { - type: 'add_mark', - path, - offset, - length, - mark, - inverse, - } - - return transform.operate(operation) + return transform.addMarkOperation(path, offset, length, mark) } /** @@ -55,21 +36,7 @@ export function insertNodeByKey(transform, key, index, node) { const { document } = state const path = document.getPath(key) const newPath = path.slice().push(index) - - const inverse = { - type: 'remove_node', - path: newPath, - } - - const operation = { - type: 'insert_node', - path, - index, - node, - inverse, - } - - return transform.operate(operation) + return transform.insertNodeOperation(path, index, node) } /** @@ -87,23 +54,7 @@ export function insertTextByKey(transform, key, offset, text, marks) { const { state } = transform const { document } = state const path = document.getPath(key) - - const inverse = { - type: 'remove_text', - path, - offset, - length: text.length, - } - - const operation = { - type: 'insert_text', - path, - offset, - text, - marks, - } - - return transform.operate(operation) + return transform.insertTextOperation(path, offset, text, marks) } /** @@ -119,30 +70,9 @@ export function insertTextByKey(transform, key, offset, text, marks) { export function moveNodeByKey(transform, key, newKey, newIndex) { const { state } = transform const { document } = state - const node = document.assertDescendant(key) const path = document.getPath(key) - const parent = document.getParent(key) - const parentPath = path.slice(0, -1) - const parentIndex = path[path.length - 1] const newPath = document.getPath(newKey) - const nodePath = newPath.slice().concat([newIndex]) - - const inverse = { - type: 'move_node', - path: nodePath, - newPath: parentPath, - newIndex: parentIndex, - } - - const operation = { - type: 'move_node', - path, - newPath, - newIndex, - inverse, - } - - return transform.operate(operation) + return transform.moveNodeOperation(path, newPath, newIndex) } /** @@ -158,29 +88,10 @@ export function moveNodeByKey(transform, key, newKey, newIndex) { export function removeMarkByKey(transform, key, offset, length, mark) { mark = Normalize.mark(mark) - const { state } = transform const { document } = state const path = document.getPath(key) - - const inverse = { - type: 'add_mark', - path, - offset, - length, - mark, - } - - const operation = { - type: 'remove_mark', - path, - offset, - length, - mark, - inverse, - } - - return transform.operate(operation) + return transform.removeMarkOperation(path, offset, length, mark) } /** @@ -194,25 +105,8 @@ export function removeMarkByKey(transform, key, offset, length, mark) { export function removeNodeByKey(transform, key) { const { state } = transform const { document } = state - const node = document.assertDescendant(key) const path = document.getPath(key) - const parentPath = path.slice(0, -1) - const parentIndex = path.slice(-1) - - const inverse = { - type: 'insert_node', - path: parentPath, - index: parentIndex, - node, - } - - const operation = { - type: 'remove_node', - path, - inverse, - } - - return transform.operate(operation) + return transform.removeNodeOperation(path) } /** @@ -229,19 +123,7 @@ export function removeTextByKey(transform, key, offset, length) { const { state } = transform const { document } = state const path = document.getPath(key) - - // TODO! - const inverse = {} - - const operation = { - type: 'remove_text', - path, - offset, - length, - inverse, - } - - return transform.operate(operation) + return transform.removeTextOperation(path, offset, length) } /** @@ -258,36 +140,10 @@ export function removeTextByKey(transform, key, offset, length) { export function setMarkByKey(transform, key, offset, length, mark, properties) { mark = Normalize.mark(mark) properties = Normalize.markProperties(properties) - const { state } = transform const { document } = state const path = document.getPath(key) - const prevProps = {} - - for (const k in properties) { - prevProps[k] = mark[k] - } - - const inverse = { - type: 'set_mark', - path, - offset, - length, - mark, - properties: prevProps, - } - - const operation = { - type: 'set_mark', - path, - offset, - length, - mark, - properties, - inverse, - } - - return transform.operate(operation) + return transform.setMarkOperation(path, offset, length, mark, properties) } /** @@ -301,31 +157,11 @@ export function setMarkByKey(transform, key, offset, length, mark, properties) { export function setNodeByKey(transform, key, properties) { properties = Normalize.nodeProperties(properties) - const { state } = transform const { document } = state const node = document.assertDescendant(key) const path = document.getPath(key) - const prevProps = {} - - for (const k in properties) { - prevProps[k] = node[k] - } - - const inverse = { - type: 'set_node', - path, - properties: prevProps - } - - const operation = { - type: 'set_node', - path, - properties, - inverse, - } - - return transform.operate(operation) + return transform.setNodeOperation(path, properties) } /** @@ -341,17 +177,5 @@ export function splitNodeByKey(transform, key, offset) { const { state } = transform const { document } = state const path = document.getPath(key) - - // TODO! - const inverse = {} - - const operation = { - type: 'split_node', - path, - offset, - inverse, - } - - return transform.operate(operation) + return transform.splitNodeOperation(path, offset) } - diff --git a/lib/transforms/index.js b/lib/transforms/index.js index 81c62383f..9bb9a207a 100644 --- a/lib/transforms/index.js +++ b/lib/transforms/index.js @@ -1,4 +1,30 @@ +/** + * Apply operation. + */ + +import { + applyOperation, +} from './apply-operation' + +/** + * Operations. + */ + +import { + addMarkOperation, + insertNodeOperation, + insertTextOperation, + moveNodeOperation, + removeMarkOperation, + removeNodeOperation, + removeTextOperation, + setMarkOperation, + setNodeOperation, + setSelectionOperation, + splitNodeOperation, +} from './operations' + /** * At range. */ @@ -98,7 +124,6 @@ import { moveTo, moveToOffsets, moveToRangeOf, - setSelection, } from './on-selection' /** @@ -118,6 +143,28 @@ import { export default { + /** + * Apply operation. + */ + + applyOperation, + + /** + * Operations. + */ + + addMarkOperation, + insertNodeOperation, + insertTextOperation, + moveNodeOperation, + removeMarkOperation, + removeNodeOperation, + removeTextOperation, + setMarkOperation, + setNodeOperation, + setSelectionOperation, + splitNodeOperation, + /** * At range. */ @@ -210,7 +257,6 @@ export default { moveTo, moveToOffsets, moveToRangeOf, - setSelection, /** * Normalize. diff --git a/lib/transforms/on-selection.js b/lib/transforms/on-selection.js index a9651bfab..18332c194 100644 --- a/lib/transforms/on-selection.js +++ b/lib/transforms/on-selection.js @@ -1,7 +1,4 @@ -import Normalize from '../utils/normalize' -import Selection from '../models/selection' - /** * Blur the selection. * @@ -13,7 +10,7 @@ export function blur(transform) { const { state } = transform const { selection } = state const sel = selection.blur() - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -27,7 +24,7 @@ export function collapseToAnchor(transform) { const { state } = transform const { selection } = state const sel = selection.collapseToAnchor() - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -41,7 +38,7 @@ export function collapseToEnd(transform) { const { state } = transform const { selection } = state const sel = selection.collapseToEnd() - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -55,7 +52,7 @@ export function collapseToFocus(transform) { const { state } = transform const { selection } = state const sel = selection.collapseToFocus() - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -69,7 +66,7 @@ export function collapseToStart(transform) { const { state } = transform const { selection } = state const sel = selection.collapseToStart() - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -84,7 +81,7 @@ export function collapseToEndOf(transform, node) { const { state } = transform const { selection } = state const sel = selection.collapseToEndOf(node) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -103,7 +100,7 @@ export function collapseToEndOfNextBlock(transform) { if (!next) return transform const sel = selection.collapseToEndOf(next) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -122,7 +119,7 @@ export function collapseToEndOfNextText(transform) { if (!next) return transform const sel = selection.collapseToEndOf(next) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -141,7 +138,7 @@ export function collapseToEndOfPreviousBlock(transform) { if (!previous) return transform const sel = selection.collapseToEndOf(previous) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -160,7 +157,7 @@ export function collapseToEndOfPreviousText(transform) { if (!previous) return transform const sel = selection.collapseToEndOf(previous) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -175,7 +172,7 @@ export function collapseToStartOf(transform, node) { const { state } = transform const { selection } = state const sel = selection.collapseToStartOf(node) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -194,7 +191,7 @@ export function collapseToStartOfNextBlock(transform) { if (!next) return transform const sel = selection.collapseToStartOf(next) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -213,7 +210,7 @@ export function collapseToStartOfNextText(transform) { if (!next) return transform const sel = selection.collapseToStartOf(next) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -232,7 +229,7 @@ export function collapseToStartOfPreviousBlock(transform) { if (!previous) return transform const sel = selection.collapseToStartOf(previous) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -251,7 +248,7 @@ export function collapseToStartOfPreviousText(transform) { if (!previous) return transform const sel = selection.collapseToStartOf(previous) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -266,7 +263,7 @@ export function extendBackward(transform, n) { const { state } = transform const { document, selection } = state const sel = selection.extendBackward(n).normalize(document) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -281,7 +278,7 @@ export function extendForward(transform, n) { const { state } = transform const { document, selection } = state const sel = selection.extendForward(n).normalize(document) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -296,7 +293,7 @@ export function extendToEndOf(transform, node) { const { state } = transform const { document, selection } = state const sel = selection.extendToEndOf(node).normalize(document) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -311,7 +308,7 @@ export function extendToStartOf(transform, node) { const { state } = transform const { document, selection } = state const sel = selection.extendToStartOf(node).normalize(document) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -325,7 +322,7 @@ export function focus(transform) { const { state } = transform const { selection } = state const sel = selection.focus() - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -340,7 +337,7 @@ export function moveBackward(transform, n) { const { state } = transform const { document, selection } = state const sel = selection.moveBackward(n).normalize(document) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -355,7 +352,7 @@ export function moveForward(transform, n) { const { state } = transform const { document, selection } = state const sel = selection.moveForward(n).normalize(document) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -367,11 +364,7 @@ export function moveForward(transform, n) { */ export function moveTo(transform, properties) { - properties = Normalize.selection(properties) - const { state } = transform - const { document, selection } = state - const sel = selection.merge(properties).normalize(document) - return transform.setSelection(sel) + return transform.setSelectionOperation(properties) } /** @@ -387,7 +380,7 @@ export function moveToOffsets(transform, anchor, fokus) { const { state } = transform const { document, selection } = state const sel = selection.moveToOffsets(anchor, fokus) - return transform.setSelection(sel) + return transform.setSelectionOperation(sel) } /** @@ -403,42 +396,5 @@ export function moveToRangeOf(transform, start, end) { const { state } = transform const { document, selection } = state const sel = selection.moveToRangeOf(start, end).normalize(document) - return transform.setSelection(sel) -} - -/** - * Set the selection to a new `selection`. - * - * @param {Transform} transform - * @param {Mixed} selection - * @return {Transform} - */ - -export function setSelection(transform, properties) { - properties = Normalize.selectionProperties(properties) - - const { state } = transform - const { selection } = state - const prevProps = {} - - if (properties.marks == selection.marks) { - properties.marks = null - } - - for (const k in properties) { - prevProps[k] = selection[k] - } - - const inverse = { - type: 'set_selection', - properties: prevProps - } - - const operation = { - type: 'set_selection', - properties, - inverse, - } - - return transform.operate(operation) + return transform.setSelectionOperation(sel) } diff --git a/lib/transforms/operations.js b/lib/transforms/operations.js index 9a84cf51f..1e3b8e583 100644 --- a/lib/transforms/operations.js +++ b/lib/transforms/operations.js @@ -1,300 +1,375 @@ -import Debug from 'debug' +import Normalize from '../utils/normalize' import uid from '../utils/uid' -/** - * Debug. - * - * @type {Function} - */ - -const debug = Debug('slate:operation') - /** * Add mark to text at `offset` and `length` in node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Number} offset + * @param {Number} length + * @param {Mixed} mark + * @return {Transform} */ -function addMark(state, operation) { - debug('add_mark', operation) - const { path, offset, length, mark } = operation - let { document } = state - let node = document.assertPath(path) - node = node.addMark(offset, length, mark) - document = document.updateDescendant(node) - state = state.merge({ document }) - return state +export function addMarkOperation(transform, path, offset, length, mark) { + const inverse = [{ + type: 'remove_mark', + path, + offset, + length, + mark, + }] + + const operation = { + type: 'add_mark', + path, + offset, + length, + mark, + inverse, + } + + return transform.applyOperation(operation) } /** * Insert a `node` at `index` in a node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Number} index + * @param {Node} node + * @return {Transform} */ -function insertNode(state, operation) { - debug('insert_node', operation) - const { path, index, node } = operation - let { document } = state - let parent = document.assertPath(path) - const isParent = document == parent - const nodes = parent.nodes.splice(index, 0, node) - parent = parent.merge({ nodes }) - document = isParent ? parent : document.updateDescendant(parent) - state = state.merge({ document }) - return state +export function insertNodeOperation(transform, path, index, node) { + const inversePath = path.slice().concat([index]) + const inverse = [{ + type: 'remove_node', + path: inversePath, + }] + + const operation = { + type: 'insert_node', + path, + index, + node, + inverse, + } + + return transform.applyOperation(operation) } /** * Insert `text` at `offset` in node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Number} offset + * @param {String} text + * @param {Set} marks (optional) + * @return {Transform} */ -function insertText(state, operation) { - debug('insert_text', operation) - const { path, offset, text, marks } = operation - let { document } = state - let node = document.assertPath(path) - node = node.insertText(offset, text, marks) - document = document.updateDescendant(node) - state = state.merge({ document }) - return state +export function insertTextOperation(transform, path, offset, text, marks) { + const inverseLength = text.length + const inverse = [{ + type: 'remove_text', + path, + offset, + length: inverseLength, + }] + + const operation = { + type: 'insert_text', + path, + offset, + text, + marks, + inverse, + } + + return transform.applyOperation(operation) } /** - * Move a node by `path` to a new parent by `path` and `index`. + * Move a node by `path` to a `newPath` and `newIndex`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Array} newPath + * @param {Number} newIndex + * @return {Transform} */ -function moveNode(state, operation) { - debug('move_node', operation) - const { path, newPath, newIndex } = operation - let { document } = state - const node = document.assertPath(path) +export function moveNodeOperation(transform, path, newPath, newIndex) { + const { state } = transform + const { document } = state + const parentPath = path.slice(0, -1) + const parentIndex = path[path.length - 1] + const inversePath = newPath.slice().concat([newIndex]) - let parent = document.getParent(node) - const isParent = document == parent - const index = parent.nodes.indexOf(node) - parent = parent.removeNode(index) - document = isParent ? parent : document.updateDescendant(parent) + const inverse = [{ + type: 'move_node', + path: inversePath, + newPath: parentPath, + newIndex: parentIndex, + }] - let target = document.assertPath(newPath) - const isTarget = document == target - target = target.insertNode(newIndex, node) - document = isTarget ? target : document.updateDescendant(target) + const operation = { + type: 'move_node', + path, + newPath, + newIndex, + inverse, + } - state = state.merge({ document }) - return state + return transform.applyOperation(operation) } /** * Remove mark from text at `offset` and `length` in node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Number} offset + * @param {Number} length + * @param {Mark} mark + * @return {Transform} */ -function removeMark(state, operation) { - debug('remove_mark', operation) - const { path, offset, length, mark } = operation - let { document } = state - let node = document.assertPath(path) - node = node.removeMark(offset, length, mark) - document = document.updateDescendant(node) - state = state.merge({ document }) - return state +export function removeMarkOperation(transform, path, offset, length, mark) { + const inverse = [{ + type: 'add_mark', + path, + offset, + length, + mark, + }] + + const operation = { + type: 'remove_mark', + path, + offset, + length, + mark, + inverse, + } + + return transform.applyOperation(operation) } /** * Remove a node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @return {Transform} */ -function removeNode(state, operation) { - debug('remove_node', operation) - const { path } = operation - let { document } = state +export function removeNodeOperation(transform, path) { + const { state } = transform + const { document } = state const node = document.assertPath(path) - let parent = document.getParent(node) - const index = parent.nodes.indexOf(node) - const isParent = document == parent - parent = parent.removeNode(index) - document = isParent ? parent : document.updateDescendant(parent) - document = document.normalize() - state = state.merge({ document }) - return state + const inversePath = path.slice(0, -1) + const inverseIndex = path.slice(-1) + + const inverse = [{ + type: 'insert_node', + path: inversePath, + index: inverseIndex, + node, + }] + + const operation = { + type: 'remove_node', + path, + inverse, + } + + return transform.applyOperation(operation) } /** * Remove text at `offset` and `length` in node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Number} offset + * @param {Number} length + * @return {Transform} */ -function removeText(state, operation) { - debug('remove_text', operation) - const { path, offset, length } = operation - let { document } = state - let node = document.assertPath(path) - node = node.removeText(offset, length) - document = document.updateDescendant(node) - document = document.normalize() - state = state.merge({ document }) - return state +export function removeTextOperation(transform, path, offset, length) { + const { state } = transform + const { document } = state + const node = document.assertPath(path) + const ranges = node.getRanges() + const inverse = [] + + ranges.reduce((start, range) => { + const { text, marks } = range + const end = start + text.length + if (start > offset + length) return + if (end <= offset) return + + const endOffset = Math.min(end, offset + length) + const string = text.slice(offset, endOffset) + + inverse.push({ + type: 'insert_text', + path, + offset, + text: string, + marks, + }) + + return end + }, 0) + + const operation = { + type: 'remove_text', + path, + offset, + length, + inverse, + } + + return transform.applyOperation(operation) } /** * Set `properties` on mark on text at `offset` and `length` in node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Number} offset + * @param {Number} length + * @param {Mark} mark + * @return {Transform} */ -function setMark(state, operation) { - debug('set_mark', operation) - const { path, offset, length, mark, properties } = operation - let { document } = state - let node = document.assertPath(path) - node = node.updateMark(offset, length, mark, properties) - document = document.updateDescendant(node) - state = state.merge({ document }) - return state +export function setMarkOperation(transform, path, offset, length, mark, properties) { + const inverseProps = {} + + for (const k in properties) { + inverseProps[k] = mark[k] + } + + const inverse = [{ + type: 'set_mark', + path, + offset, + length, + mark, + properties: inverseProps, + }] + + const operation = { + type: 'set_mark', + path, + offset, + length, + mark, + properties, + inverse, + } + + return transform.applyOperation(operation) } /** * Set `properties` on a node by `path`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Object || String} properties + * @return {Transform} */ -function setNode(state, operation) { - debug('set_node', operation) - const { path, properties } = operation - let { document } = state - let node = document.assertPath(path) - node = node.merge(properties) - document = document.updateDescendant(node) - document = document.normalize() - state = state.merge({ document }) - return state +export function setNodeOperation(transform, path, properties) { + const { state } = transform + const { document } = state + const node = document.assertPath(path) + const inverseProps = {} + + for (const k in properties) { + inverseProps[k] = node[k] + } + + const inverse = [{ + type: 'set_node', + path, + properties: inverseProps + }] + + const operation = { + type: 'set_node', + path, + properties, + inverse, + } + + return transform.applyOperation(operation) } /** - * Set `properties` on the selection. + * Set the selection to a new `selection`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Mixed} selection + * @return {Transform} */ -function setSelection(state, operation) { - debug('set_selection', operation) - let { properties } = operation - let { selection } = state - selection = selection.merge(properties) - state = state.merge({ selection }) - return state +export function setSelectionOperation(transform, properties) { + properties = Normalize.selectionProperties(properties) + + const { state } = transform + const { selection } = state + const prevProps = {} + + if (properties.marks == selection.marks) { + properties.marks = null + } + + for (const k in properties) { + prevProps[k] = selection[k] + } + + const inverse = [{ + type: 'set_selection', + properties: prevProps + }] + + const operation = { + type: 'set_selection', + properties, + inverse, + } + + return transform.applyOperation(operation) } /** * Split a node by `path` at `offset`. * - * @param {State} state - * @param {Object} operation - * @return {State} + * @param {Transform} transform + * @param {Array} path + * @param {Number} offset + * @return {Transform} */ -function splitNode(state, operation) { - debug('split_node', operation) - const { path, offset } = operation - let { document } = state - let node = document.assertPath(path) - let parent = document.getParent(node) - const isParent = document == parent - const index = parent.nodes.indexOf(node) +export function splitNodeOperation(transform, path, offset) { + const inverse = [] - let child = node - let one - let two - - if (node.kind != 'text') { - child = node.getTextAtOffset(offset) + const operation = { + type: 'split_node', + path, + offset, + inverse, } - while (child && child != parent) { - if (child.kind == 'text') { - const i = node.kind == 'text' ? offset : offset - node.getOffset(child) - const { characters } = child - const oneChars = characters.take(i) - const twoChars = characters.skip(i) - one = child.merge({ characters: oneChars }) - two = child.merge({ characters: twoChars, key: uid() }) - } - - else { - const { nodes } = child - const oneNodes = nodes.takeUntil(n => n.key == one.key).push(one) - const twoNodes = nodes.skipUntil(n => n.key == one.key).rest().unshift(two) - one = child.merge({ nodes: oneNodes }) - two = child.merge({ nodes: twoNodes, key: uid() }) - } - - child = document.getParent(child) - } - - parent = parent.removeNode(index) - parent = parent.insertNode(index, two) - parent = parent.insertNode(index, one) - document = isParent ? parent : document.updateDescendant(parent) - state = state.merge({ document }) - return state -} - -/** - * Export. - * - * @type {Object} - */ - -export default { - - // Text operations. - insert_text: insertText, - remove_text: removeText, - - // Mark operations. - add_mark: addMark, - remove_mark: removeMark, - set_mark: setMark, - - // Node operations. - insert_node: insertNode, - move_node: moveNode, - remove_node: removeNode, - set_node: setNode, - split_node: splitNode, - - // Selection operations. - set_selection: setSelection, - + return transform.applyOperation(operation) }